@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.
Files changed (68) 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 +16 -0
  5. package/dist/src/Object.d.ts.map +1 -1
  6. package/dist/src/Object.js +16 -0
  7. package/dist/src/Result.d.ts +36 -29
  8. package/dist/src/Result.d.ts.map +1 -1
  9. package/dist/src/Sqlite.d.ts +9 -7
  10. package/dist/src/Sqlite.d.ts.map +1 -1
  11. package/dist/src/Sqlite.js +8 -0
  12. package/dist/src/Task.d.ts.map +1 -1
  13. package/dist/src/Task.js +6 -4
  14. package/dist/src/Type.d.ts +30 -2
  15. package/dist/src/Type.d.ts.map +1 -1
  16. package/dist/src/Type.js +82 -0
  17. package/dist/src/local-first/Db.d.ts +1 -5
  18. package/dist/src/local-first/Db.d.ts.map +1 -1
  19. package/dist/src/local-first/Db.js +109 -108
  20. package/dist/src/local-first/Evolu.d.ts +3 -0
  21. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  22. package/dist/src/local-first/Evolu.js +6 -7
  23. package/dist/src/local-first/LocalAuth.d.ts +2 -2
  24. package/dist/src/local-first/Owner.d.ts +3 -0
  25. package/dist/src/local-first/Owner.d.ts.map +1 -1
  26. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  27. package/dist/src/local-first/Protocol.js +10 -10
  28. package/dist/src/local-first/PublicKysely.js +1 -1
  29. package/dist/src/local-first/Query.d.ts +54 -6
  30. package/dist/src/local-first/Query.d.ts.map +1 -1
  31. package/dist/src/local-first/Query.js +130 -11
  32. package/dist/src/local-first/Relay.d.ts.map +1 -1
  33. package/dist/src/local-first/Relay.js +17 -18
  34. package/dist/src/local-first/Schema.d.ts +15 -14
  35. package/dist/src/local-first/Schema.d.ts.map +1 -1
  36. package/dist/src/local-first/Schema.js +43 -51
  37. package/dist/src/local-first/Storage.d.ts +2 -2
  38. package/dist/src/local-first/Storage.d.ts.map +1 -1
  39. package/dist/src/local-first/Storage.js +2 -6
  40. package/dist/src/local-first/Sync.d.ts +9 -4
  41. package/dist/src/local-first/Sync.d.ts.map +1 -1
  42. package/dist/src/local-first/Sync.js +132 -59
  43. package/dist/src/local-first/index.d.ts +0 -1
  44. package/dist/src/local-first/index.d.ts.map +1 -1
  45. package/dist/src/local-first/index.js +0 -1
  46. package/package.json +1 -1
  47. package/src/Function.ts +74 -0
  48. package/src/Object.ts +20 -0
  49. package/src/Result.ts +38 -29
  50. package/src/Sqlite.ts +18 -7
  51. package/src/Task.ts +6 -4
  52. package/src/Type.ts +134 -1
  53. package/src/local-first/Db.ts +181 -209
  54. package/src/local-first/Evolu.ts +9 -6
  55. package/src/local-first/LocalAuth.ts +2 -2
  56. package/src/local-first/Owner.ts +4 -0
  57. package/src/local-first/Protocol.ts +10 -16
  58. package/src/local-first/PublicKysely.ts +1 -1
  59. package/src/local-first/Query.ts +216 -21
  60. package/src/local-first/Relay.ts +20 -24
  61. package/src/local-first/Schema.ts +83 -70
  62. package/src/local-first/Storage.ts +3 -8
  63. package/src/local-first/Sync.ts +190 -66
  64. package/src/local-first/index.ts +0 -1
  65. package/dist/src/local-first/Diff.d.ts +0 -43
  66. package/dist/src/local-first/Diff.d.ts.map +0 -1
  67. package/dist/src/local-first/Diff.js +0 -97
  68. package/src/local-first/Diff.ts +0 -144
@@ -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;
@@ -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 { systemColumns } from "./Schema.js";
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 = forbiddenSystemColumns.filter((key) => key in value);
263
+ const invalidColumns = systemColumnsWithId.filter((key) => key in value);
269
264
  if (invalidColumns.length > 0)
270
265
  return err<ValidDbChangeValuesError>({
271
266
  type: "ValidDbChangeValues",