@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.
Files changed (77) hide show
  1. package/dist/src/Function.d.ts +61 -0
  2. package/dist/src/Function.d.ts.map +1 -1
  3. package/dist/src/Function.js +19 -0
  4. package/dist/src/Object.d.ts +32 -1
  5. package/dist/src/Object.d.ts.map +1 -1
  6. package/dist/src/Object.js +32 -1
  7. package/dist/src/Order.d.ts +2 -1
  8. package/dist/src/Order.d.ts.map +1 -1
  9. package/dist/src/Order.js +1 -0
  10. package/dist/src/Result.d.ts +36 -29
  11. package/dist/src/Result.d.ts.map +1 -1
  12. package/dist/src/Sqlite.d.ts +9 -7
  13. package/dist/src/Sqlite.d.ts.map +1 -1
  14. package/dist/src/Sqlite.js +8 -0
  15. package/dist/src/Task.d.ts.map +1 -1
  16. package/dist/src/Task.js +6 -4
  17. package/dist/src/Type.d.ts +30 -2
  18. package/dist/src/Type.d.ts.map +1 -1
  19. package/dist/src/Type.js +82 -0
  20. package/dist/src/local-first/Db.d.ts +1 -5
  21. package/dist/src/local-first/Db.d.ts.map +1 -1
  22. package/dist/src/local-first/Db.js +112 -117
  23. package/dist/src/local-first/Evolu.d.ts +3 -0
  24. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  25. package/dist/src/local-first/Evolu.js +14 -12
  26. package/dist/src/local-first/LocalAuth.d.ts +2 -2
  27. package/dist/src/local-first/Owner.d.ts +3 -0
  28. package/dist/src/local-first/Owner.d.ts.map +1 -1
  29. package/dist/src/local-first/Protocol.d.ts +1 -1
  30. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  31. package/dist/src/local-first/Protocol.js +12 -12
  32. package/dist/src/local-first/PublicKysely.js +1 -1
  33. package/dist/src/local-first/Query.d.ts +54 -6
  34. package/dist/src/local-first/Query.d.ts.map +1 -1
  35. package/dist/src/local-first/Query.js +130 -11
  36. package/dist/src/local-first/Relay.d.ts.map +1 -1
  37. package/dist/src/local-first/Relay.js +17 -18
  38. package/dist/src/local-first/Schema.d.ts +15 -14
  39. package/dist/src/local-first/Schema.d.ts.map +1 -1
  40. package/dist/src/local-first/Schema.js +43 -51
  41. package/dist/src/local-first/Storage.d.ts +2 -2
  42. package/dist/src/local-first/Storage.d.ts.map +1 -1
  43. package/dist/src/local-first/Storage.js +2 -6
  44. package/dist/src/local-first/Sync.d.ts +10 -7
  45. package/dist/src/local-first/Sync.d.ts.map +1 -1
  46. package/dist/src/local-first/Sync.js +154 -103
  47. package/dist/src/local-first/Timestamp.d.ts +13 -0
  48. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  49. package/dist/src/local-first/Timestamp.js +14 -1
  50. package/dist/src/local-first/index.d.ts +0 -1
  51. package/dist/src/local-first/index.d.ts.map +1 -1
  52. package/dist/src/local-first/index.js +0 -1
  53. package/package.json +1 -1
  54. package/src/Function.ts +74 -0
  55. package/src/Object.ts +40 -1
  56. package/src/Order.ts +2 -1
  57. package/src/Result.ts +38 -29
  58. package/src/Sqlite.ts +18 -7
  59. package/src/Task.ts +6 -4
  60. package/src/Type.ts +134 -1
  61. package/src/local-first/Db.ts +182 -217
  62. package/src/local-first/Evolu.ts +21 -10
  63. package/src/local-first/LocalAuth.ts +2 -2
  64. package/src/local-first/Owner.ts +4 -0
  65. package/src/local-first/Protocol.ts +13 -19
  66. package/src/local-first/PublicKysely.ts +1 -1
  67. package/src/local-first/Query.ts +216 -21
  68. package/src/local-first/Relay.ts +20 -24
  69. package/src/local-first/Schema.ts +83 -70
  70. package/src/local-first/Storage.ts +3 -8
  71. package/src/local-first/Sync.ts +215 -135
  72. package/src/local-first/Timestamp.ts +14 -1
  73. package/src/local-first/index.ts +0 -1
  74. package/dist/src/local-first/Diff.d.ts +0 -43
  75. package/dist/src/local-first/Diff.d.ts.map +0 -1
  76. package/dist/src/local-first/Diff.js +0 -97
  77. 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 writeResult = await deps.storage.writeMessages(
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 (!writeResult.ok) return ok({ type: "no-response" });
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 syncResult = sync(deps)(ranges, output, ownerIdBytes);
1006
+ const result = sync(deps)(ranges, output, ownerIdBytes);
1010
1007
 
1011
1008
  // Client sync error (handled via Storage) or no changes.
1012
- if (!syncResult.ok || !syncResult.value) {
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 writeResult = await deps.storage.writeMessages(
1130
- ownerIdBytes,
1131
- messages,
1132
- );
1126
+ const result = await deps.storage.writeMessages(ownerIdBytes, messages);
1133
1127
 
1134
- if (!writeResult.ok) {
1128
+ if (!result.ok) {
1135
1129
  const errorCode =
1136
- writeResult.error.type === "StorageWriteError"
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 syncResult = sync(deps)(ranges, output, ownerIdBytes);
1186
+ const result = sync(deps)(ranges, output, ownerIdBytes);
1193
1187
 
1194
- const message = syncResult.ok
1188
+ const message = result.ok
1195
1189
  ? output.unwrap()
1196
1190
  : createProtocolMessageBuffer(ownerId, {
1197
1191
  messageType: MessageType.Response,
1198
- errorCode: syncResult.error,
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 = Object.create(null) as Record<string, SqliteValue>;
1813
+ const values = createRecord<string, SqliteValue>();
1820
1814
 
1821
1815
  for (let i = 0; i < length; i++) {
1822
1816
  const column = decodeString(buffer);
@@ -13,7 +13,7 @@ import {
13
13
  TableNode,
14
14
  ValueNode,
15
15
  } from "kysely";
16
- import { kyselyJsonIdentifier } from "./Diff.js";
16
+ import { kyselyJsonIdentifier } from "./Query.js";
17
17
 
18
18
  export { sql } from "kysely";
19
19
  export type { NotNull } from "kysely";
@@ -1,14 +1,26 @@
1
1
  import { Brand } from "../Brand.js";
2
2
  import { bytesToHex, hexToBytes } from "../Buffer.js";
3
- import { objectToEntries } from "../Object.js";
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
+ };
@@ -217,13 +217,8 @@ export const createRelaySqliteStorage =
217
217
  (storedBytes ?? 0) + incomingBytes,
218
218
  );
219
219
 
220
- const withinQuotaResult = config.isOwnerWithinQuota(
221
- ownerId,
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
- const insertTimestampResult = sqliteStorageBase.insertTimestamp(
244
- ownerIdBytes,
245
- timestamp,
246
- strategy,
247
- );
248
- if (!insertTimestampResult.ok) return insertTimestampResult;
249
-
250
- const insertMessage = deps.sqlite.exec(sql`
251
- insert into evolu_message ("ownerId", "timestamp", "change")
252
- values (${ownerIdBytes}, ${timestamp}, ${change})
253
- on conflict do nothing;
254
- `);
255
- if (!insertMessage.ok) return insertMessage;
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
- const updateUsage = updateOwnerUsage(deps)(
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 { mapObject, objectToEntries, ReadonlyRecord } from "../Object.js";
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 = objectToEntries(schema).map(([tableName, table]) => ({
173
- name: tableName,
174
- columns: objectToEntries(table)
175
- .filter(([k]) => k !== "id")
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 = Object.keys(SystemColumns.props);
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: array(DbTable),
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 map = new Map<string, Array<string>>();
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
- if (!map.has(tableName)) map.set(tableName, []);
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: DbSchema,
536
- options?: { ignoreIndexes: boolean },
549
+ currentSchema?: DbSchema,
537
550
  ): Result<void, SqliteError> => {
538
551
  const queries: Array<SqliteQuery> = [];
539
552
 
540
- newSchema.tables.forEach((newTable) => {
541
- const currentTable = currentSchema.tables.find(
542
- (t) => t.name === newTable.name,
543
- );
544
- if (!currentTable) {
545
- queries.push(createAppTable(newTable));
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
- newTable.columns
548
- .filter((newColumn) => !currentTable.columns.includes(newColumn))
549
- .forEach((newColumn) => {
550
- queries.push(sql`
551
- alter table ${sql.identifier(newTable.name)}
552
- add column ${sql.identifier(newColumn)} blob;
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 = (table: DbTable) => sql`
592
- create table ${sql.identifier(table.name)} (
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;