@evolu/common 8.11.0 → 8.12.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.
@@ -15,6 +15,7 @@ import {
15
15
  assertNonEmptyReadonlyArray,
16
16
  assertNotUndefined,
17
17
  } from "../Assert.ts";
18
+ import { createBuffer } from "../Bytes.ts";
18
19
  import { createCallbacks } from "../Callbacks.ts";
19
20
  import type { ConsoleDep } from "../Console.ts";
20
21
  import { createConsole } from "../Console.ts";
@@ -28,11 +29,20 @@ import {
28
29
  type LockManagerDep,
29
30
  } from "../LockManager.ts";
30
31
  import { createMicrotaskBatch } from "../Microtask.ts";
32
+ import {
33
+ createMutableRecord,
34
+ objectToEntries,
35
+ type ReadonlyRecord,
36
+ } from "../Object.ts";
31
37
  import type { FlushSyncDep, ReloadAppDep } from "../Platform.ts";
32
38
  import { createRefCountByKey } from "../RefCount.ts";
33
39
  import { err, ok } from "../Result.ts";
34
40
  import { isNonEmptySet } from "../Set.ts";
35
- import { SqliteBoolean, sqliteBooleanToBoolean } from "../Sqlite.ts";
41
+ import {
42
+ SqliteBoolean,
43
+ sqliteBooleanToBoolean,
44
+ type SqliteValue,
45
+ } from "../Sqlite.ts";
36
46
  import type { Listener, ReadonlyStore, Unsubscribe } from "../Store.ts";
37
47
  import { createStore } from "../Store.ts";
38
48
  import type { Task } from "../Task.ts";
@@ -41,8 +51,9 @@ import {
41
51
  createId,
42
52
  createIdFromString,
43
53
  type ExtractTyped,
44
- type Id,
54
+ Id,
45
55
  Name,
56
+ PositiveInt,
46
57
  type TypeError,
47
58
  UrlSafeString,
48
59
  } from "../Type.ts";
@@ -61,7 +72,12 @@ import type {
61
72
  SyncOwner,
62
73
  } from "./Owner.ts";
63
74
  import { createOwnerWebSocketTransport } from "./Owner.ts";
64
- import type { ProtocolError, ProtocolQuotaError } from "./Protocol.ts";
75
+ import {
76
+ encodeDbChange,
77
+ type ProtocolError,
78
+ type ProtocolQuotaError,
79
+ type ProtocolVersionError,
80
+ } from "./Protocol.ts";
65
81
  import type {
66
82
  Queries,
67
83
  QueriesToQueryRowsPromises,
@@ -77,9 +93,10 @@ import type {
77
93
  Mutation,
78
94
  MutationChange,
79
95
  MutationOptions,
96
+ MutationValues,
80
97
  ValidateSchema,
81
98
  } from "./Schema.ts";
82
- import { evoluSchemaToSqliteSchema } from "./Schema.ts";
99
+ import { evoluSchemaToSqliteSchema, isLocalOnlyTable } from "./Schema.ts";
83
100
  import type {
84
101
  ConsoleEntryOrError,
85
102
  EvoluInput,
@@ -91,7 +108,7 @@ import type {
91
108
  } from "./Shared.ts";
92
109
  import { consoleEntryOrErrorBroadcastChannelName } from "./Shared.ts";
93
110
  import { DbChange, type StorageQuotaError } from "./Storage.ts";
94
- import type { Timestamp } from "./Timestamp.ts";
111
+ import { createTimestamp, type Timestamp } from "./Timestamp.ts";
95
112
 
96
113
  /**
97
114
  * Configuration for {@link createEvolu}.
@@ -492,6 +509,41 @@ export interface Evolu<
492
509
  */
493
510
  readonly upsert: Mutation<S, "upsert">;
494
511
 
512
+ /**
513
+ * Returns the size of a mutation of `table` with `values`, measured as
514
+ * {@link maxMutationSize} describes.
515
+ *
516
+ * Use it to check values that column Types do not bound before mutating, or
517
+ * to show how much of the limit a mutation uses. It accepts any of the
518
+ * table's columns, so the values of an insert, update, or upsert fit, and it
519
+ * ignores `id` and `isDeleted`, which are not columns. Mutations of
520
+ * local-only tables are measured too, although they are exempt from the
521
+ * limit.
522
+ *
523
+ * ### Example
524
+ *
525
+ * ```ts
526
+ * import {
527
+ * assertType,
528
+ * type Evolu,
529
+ * maxMutationSize,
530
+ * type NonEmptyTrimmedString100,
531
+ * type TestEvoluSchema,
532
+ * } from "@evolu/common";
533
+ *
534
+ * const fitsTodo = (
535
+ * evolu: Evolu<TestEvoluSchema>,
536
+ * title: NonEmptyTrimmedString100,
537
+ * ) => evolu.getMutationSize("todo", { title }) <= maxMutationSize;
538
+ *
539
+ * assertType<ReturnType<typeof fitsTodo>, boolean>();
540
+ * ```
541
+ */
542
+ readonly getMutationSize: <TableName extends keyof S>(
543
+ table: TableName,
544
+ values: Partial<MutationValues<S[TableName], "update">>,
545
+ ) => PositiveInt;
546
+
495
547
  /**
496
548
  * Load {@link Query} and return a promise with {@link QueryRows}.
497
549
  *
@@ -808,6 +860,36 @@ export type UnuseOwner = () => void;
808
860
  /**
809
861
  * Represents errors that can occur in {@link Evolu}.
810
862
  *
863
+ * Apps show them from {@link EvoluErrorDep.evoluError}.
864
+ *
865
+ * An error that leaves the app unusable deserves a modal dialog, which moves
866
+ * focus into itself and restores it when closed. Any other error is a status
867
+ * message: show it in a region with `role="alert"`, which screen readers
868
+ * announce without moving focus, so the user keeps working and a background tab
869
+ * shows it when the user returns. Avoid `alert()`, which blocks the page, once
870
+ * in every open tab.
871
+ *
872
+ * - {@link UnsupportedDbVersionError} blocks the app: the local data needs a newer
873
+ * version of it. Ask the user to update the app or close all its tabs.
874
+ * - {@link OtherBuildRunningError} blocks the app while it lasts: another version
875
+ * of the app holds the local data. Ask the user to close the app's other
876
+ * tabs. It clears when the wait ends.
877
+ * - {@link ProtocolError} does not block the app: sync with a relay failed for an
878
+ * owner, because the relay rejected or failed a request, or sent data that
879
+ * could not be decoded or verified; sync state shows the affected routes. A
880
+ * {@link ProtocolQuotaError} needs more relay quota, then
881
+ * {@link Evolu.requestSync}; a {@link ProtocolVersionError} needs an app or
882
+ * relay update.
883
+ * - {@link StorageQuotaError} does not block the app: a storage or billing quota
884
+ * was exceeded, so a batch of an owner's changes was not stored. The built-in
885
+ * client storage does not report it yet; a relay's quota arrives as
886
+ * {@link ProtocolQuotaError}.
887
+ * - {@link DecryptWithXChaCha20Poly1305Error} does not block the app: changes
888
+ * received for an owner could not be decrypted, so none of their batch was
889
+ * stored.
890
+ * - {@link UnknownError} does not block the app: Evolu logged an unexpected
891
+ * failure. Show a generic message.
892
+ *
811
893
  * @group Core
812
894
  */
813
895
  export type EvoluError =
@@ -818,6 +900,54 @@ export type EvoluError =
818
900
  | UnknownError
819
901
  | UnsupportedDbVersionError;
820
902
 
903
+ /**
904
+ * The largest {@link Mutation}, in bytes.
905
+ *
906
+ * A mutation's size is its change as encoded for sync: the table name, the ID,
907
+ * and every column name and value, with a few bytes of overhead. A string takes
908
+ * at most three bytes per UTF-16 code unit. Every mutation within the limit
909
+ * fits one protocol message, so it can always sync. A larger mutation throws
910
+ * and is not saved. Mutations of local-only tables are exempt, because they
911
+ * never sync. Measure a mutation in advance with {@link Evolu.getMutationSize}.
912
+ *
913
+ * @group Core
914
+ */
915
+ export const maxMutationSize: PositiveInt =
916
+ /*#__PURE__*/ PositiveInt.orThrow(640_000);
917
+
918
+ /** Measures a mutation as {@link maxMutationSize} describes. */
919
+ const measureMutation = (
920
+ table: string,
921
+ values: ReadonlyRecord<string, SqliteValue | undefined>,
922
+ ): PositiveInt => {
923
+ const columns = createMutableRecord<string, SqliteValue>();
924
+ for (const [column, value] of objectToEntries(values)) {
925
+ if (value !== undefined && column !== "id" && column !== "isDeleted") {
926
+ columns[column] = value;
927
+ }
928
+ }
929
+
930
+ // Measuring with the protocol's encoding keeps the limit and the message size
931
+ // from disagreeing. A separate formula would save the tab about 3 KB
932
+ // compressed, but it would be a second encoding to keep in sync, and it
933
+ // would have to overcount.
934
+ const buffer = createBuffer();
935
+ encodeDbChange(buffer, {
936
+ // Every timestamp and ID takes 16 bytes, and the flags always take one.
937
+ timestamp: createTimestamp(),
938
+ change: DbChange.orThrow({
939
+ table,
940
+ id: mutationSizeId,
941
+ values: columns,
942
+ isInsert: true,
943
+ isDelete: null,
944
+ }),
945
+ });
946
+ return buffer.getLength() as PositiveInt;
947
+ };
948
+
949
+ const mutationSizeId = /*#__PURE__*/ Id.orThrow("A".repeat(22));
950
+
821
951
  /**
822
952
  * Dependency wrapper for the shared {@link EvoluError} store.
823
953
  *
@@ -1396,6 +1526,41 @@ export const createEvolu =
1396
1526
  `Invalid DbChange for table '${String(table)}'.`,
1397
1527
  );
1398
1528
 
1529
+ // Copy binary values, so what is saved is what was passed: the caller
1530
+ // can change a Uint8Array before the batch is sent, a view of a
1531
+ // resizable buffer can grow, and a view of a shared buffer cannot be
1532
+ // sent to a SharedWorker at all. Node's Buffer passes validation and
1533
+ // its slice shares memory, so only the constructor copies reliably.
1534
+ for (const [column, value] of objectToEntries(dbChange.values)) {
1535
+ if (typeof value === "object" && value !== null) {
1536
+ changeValues[column] = new Uint8Array(value);
1537
+ }
1538
+ }
1539
+
1540
+ // A change over maxMutationSize could never sync, and a shared worker
1541
+ // hosting two databases would stop all work of that database. Correct
1542
+ // apps bound column Types, so it is a programmer error and throws like
1543
+ // the DbChange check above, before batching: it gets no timestamp, the
1544
+ // database worker never sees it, and the code after the call does not
1545
+ // run, so a form keeps its input and no row refers to the missing one.
1546
+ //
1547
+ // Rejected: reporting it through evoluError, which lets that code run
1548
+ // as if the mutation was saved; checking in the database worker, which
1549
+ // gets it already batched and cannot reach the call site; quarantine,
1550
+ // which holds changes stored for sync; and skipping it in sync, which
1551
+ // keeps range fingerprints disagreeing. Received changes are not
1552
+ // checked, and oversized changes stored before this limit are not
1553
+ // recovered, which needs a history-aware design.
1554
+ //
1555
+ // Local-only tables never sync, so they have no limit.
1556
+ if (!isLocalOnlyTable(dbChange.table)) {
1557
+ const size = measureMutation(dbChange.table, dbChange.values);
1558
+ assert(
1559
+ size <= maxMutationSize,
1560
+ `The mutation of table '${dbChange.table}' is ${size} bytes, over maxMutationSize (${maxMutationSize}). Bound the Types of its columns or check it with evolu.getMutationSize.`,
1561
+ );
1562
+ }
1563
+
1399
1564
  mutateBatch.push({
1400
1565
  change: { ...dbChange, ownerId: options?.ownerId ?? appOwner.id },
1401
1566
  onComplete: options?.onComplete,
@@ -1484,6 +1649,12 @@ export const createEvolu =
1484
1649
  update: createMutation("update"),
1485
1650
  upsert: createMutation("upsert"),
1486
1651
 
1652
+ getMutationSize: (table, values) =>
1653
+ measureMutation(
1654
+ String(table),
1655
+ values as ReadonlyRecord<string, SqliteValue | undefined>,
1656
+ ),
1657
+
1487
1658
  loadQuery,
1488
1659
  loadQueries: <Q extends Queries<S>>(
1489
1660
  queries: [...Q],
@@ -207,6 +207,23 @@ test("encodeSqliteValue/decodeSqliteValue preserves an own __proto__ JSON object
207
207
  assertEqual(decodeSqliteValue(buffer), value);
208
208
  });
209
209
 
210
+ test("encodeSqliteValue encodes JSON nested deeper than decoding allows as a string", () => {
211
+ for (const [depth, type] of [
212
+ [1_000, ProtocolValueType.Json],
213
+ [1_001, ProtocolValueType.String],
214
+ // Deep enough to overflow the stack of a recursive JSON.stringify.
215
+ [20_000, ProtocolValueType.String],
216
+ ] as const) {
217
+ const value = "[".repeat(depth) + "]".repeat(depth);
218
+ const buffer = createBuffer();
219
+
220
+ encodeSqliteValue(buffer, value);
221
+
222
+ assertEqual(buffer.unwrap()[0], type);
223
+ assertEqual(decodeSqliteValue(buffer), value);
224
+ }
225
+ });
226
+
210
227
  test("encodeSqliteValue/decodeSqliteValue property tests", () => {
211
228
  const deps = testCreateDeps();
212
229
  // Property test: round-trip encoding/decoding should preserve the value
@@ -103,9 +103,9 @@
103
103
  * fit within the limit, the protocol automatically continues synchronization in
104
104
  * subsequent rounds using range-based reconciliation.
105
105
  *
106
- * Database mutations are limited to 640KB, which is smaller than the protocol
107
- * message limit to ensure efficient sync with
108
- * {@link defaultProtocolMessageRangesMaxSize}.
106
+ * Each mutation is limited to {@link maxMutationSize}, so every change fits one
107
+ * message of {@link defaultProtocolMessageMaxSize} next to the largest ranges
108
+ * section.
109
109
  *
110
110
  * ## Why Binary?
111
111
  *
@@ -190,6 +190,7 @@ import {
190
190
  import type { Brand } from "../Brand.ts";
191
191
  import {
192
192
  type Buffer,
193
+ BufferError,
193
194
  createBuffer,
194
195
  createRunLengthEncoder,
195
196
  decodeFlags,
@@ -248,7 +249,7 @@ import {
248
249
  zeroNonNegativeInt,
249
250
  } from "../Type.ts";
250
251
  import type { Predicate } from "../Types.ts";
251
- import type { Evolu } from "./Evolu.ts";
252
+ import type { Evolu, maxMutationSize } from "./Evolu.ts";
252
253
  import {
253
254
  type Owner,
254
255
  type OwnerError,
@@ -1855,30 +1856,7 @@ export const encodeAndEncryptDbChange =
1855
1856
  (message: CrdtMessage, key: EncryptionKey): EncryptedDbChange => {
1856
1857
  const buffer = createBuffer();
1857
1858
 
1858
- encodeNonNegativeInt(buffer, protocolVersion);
1859
-
1860
- // Encode the timestamp to prevent tampering (e.g., a malicious relay
1861
- // assigning this EncryptedDbChange to a different EncryptedCrdtMessage)
1862
- buffer.extend(timestampToTimestampBytes(message.timestamp));
1863
-
1864
- encodeFlags(buffer, [
1865
- message.change.isInsert,
1866
- // Encode nullable boolean as two flags: presence + value.
1867
- message.change.isDelete != null,
1868
- message.change.isDelete ?? false,
1869
- ]);
1870
-
1871
- encodeString(buffer, message.change.table);
1872
- buffer.extend(idToIdBytes(message.change.id));
1873
-
1874
- const entries = objectToEntries(message.change.values);
1875
-
1876
- encodeLength(buffer, entries);
1877
- for (const [column, value] of entries) {
1878
- assertNotUndefined(value);
1879
- encodeString(buffer, column);
1880
- encodeSqliteValue(buffer, value);
1881
- }
1859
+ encodeDbChange(buffer, message);
1882
1860
 
1883
1861
  // Add PADMÉ padding (ignored during decoding)
1884
1862
  buffer.extend(createPadmePadding(buffer.getLength()));
@@ -1896,6 +1874,41 @@ export const encodeAndEncryptDbChange =
1896
1874
  return buffer.unwrap() as EncryptedDbChange;
1897
1875
  };
1898
1876
 
1877
+ /**
1878
+ * Encodes a {@link CrdtMessage} as {@link encodeAndEncryptDbChange} does before
1879
+ * padding and encryption.
1880
+ *
1881
+ * {@link Evolu.getMutationSize} measures mutations with this encoding, so
1882
+ * {@link maxMutationSize} limits exactly what is encoded, and every change
1883
+ * within it fits one protocol message.
1884
+ */
1885
+ export const encodeDbChange = (buffer: Buffer, message: CrdtMessage): void => {
1886
+ encodeNonNegativeInt(buffer, protocolVersion);
1887
+
1888
+ // Encode the timestamp to prevent tampering (e.g., a malicious relay
1889
+ // assigning this EncryptedDbChange to a different EncryptedCrdtMessage)
1890
+ buffer.extend(timestampToTimestampBytes(message.timestamp));
1891
+
1892
+ encodeFlags(buffer, [
1893
+ message.change.isInsert,
1894
+ // Encode nullable boolean as two flags: presence + value.
1895
+ message.change.isDelete != null,
1896
+ message.change.isDelete ?? false,
1897
+ ]);
1898
+
1899
+ encodeString(buffer, message.change.table);
1900
+ buffer.extend(idToIdBytes(message.change.id));
1901
+
1902
+ const entries = objectToEntries(message.change.values);
1903
+
1904
+ encodeLength(buffer, entries);
1905
+ for (const [column, value] of entries) {
1906
+ assertNotUndefined(value);
1907
+ encodeString(buffer, column);
1908
+ encodeSqliteValue(buffer, value);
1909
+ }
1910
+ };
1911
+
1899
1912
  /**
1900
1913
  * Decrypts and decodes an {@link EncryptedCrdtMessage} using the provided
1901
1914
  * owner's encryption key. Verifies that the embedded timestamp matches the
@@ -2069,21 +2082,30 @@ export const encodeSqliteValue = (buffer: Buffer, value: SqliteValue): void => {
2069
2082
  }
2070
2083
 
2071
2084
  const json = Json.from.parent(value);
2072
- // Only encode as Json if it survives JSON.parse/JSON.stringify round-trip.
2073
- // Some valid JSON strings like "-0E0" get normalized to "0" during parsing,
2074
- // which would cause data corruption if we don't verify round-trip safety.
2075
- if (json.ok && JSON.stringify(jsonToJsonValue(json.value)) === value) {
2085
+ if (json.ok) {
2086
+ const jsonValue = jsonToJsonValue(json.value);
2076
2087
  jsonBuffer.reset();
2077
2088
  try {
2078
- encodeJsonValue(jsonBuffer, jsonToJsonValue(json.value));
2079
- const jsonBytes = jsonBuffer.unwrap();
2080
- encodeNonNegativeInt(buffer, ProtocolValueType.Json);
2081
- encodeLength(buffer, jsonBytes);
2082
- buffer.extend(jsonBytes);
2089
+ // Encoding first rejects nesting deeper than decoding allows, before
2090
+ // the recursive JSON.stringify below could overflow the stack. Such
2091
+ // a value is encoded as a plain string.
2092
+ encodeJsonValue(jsonBuffer, jsonValue);
2093
+ // Only encode as Json if it survives JSON.parse/JSON.stringify
2094
+ // round-trip. Some valid JSON strings like "-0E0" get normalized to
2095
+ // "0" during parsing, which would cause data corruption if we don't
2096
+ // verify round-trip safety.
2097
+ if (JSON.stringify(jsonValue) === value) {
2098
+ const jsonBytes = jsonBuffer.unwrap();
2099
+ encodeNonNegativeInt(buffer, ProtocolValueType.Json);
2100
+ encodeLength(buffer, jsonBytes);
2101
+ buffer.extend(jsonBytes);
2102
+ return;
2103
+ }
2104
+ } catch (error) {
2105
+ if (!(error instanceof BufferError)) throw error;
2083
2106
  } finally {
2084
2107
  jsonBuffer.reset();
2085
2108
  }
2086
- return;
2087
2109
  }
2088
2110
 
2089
2111
  const base64Url = Base64Url.from.parent(value);
@@ -17,7 +17,7 @@ import { err, ok } from "../Result.ts";
17
17
  import type { SqliteDep } from "../Sqlite.ts";
18
18
  import { sql } from "../Sqlite.ts";
19
19
  import { createMutexByKey } from "../Task.ts";
20
- import { Name, PositiveInt, uint8ArrayToBase64Url } from "../Type.ts";
20
+ import { Name, NonNegativeInt, uint8ArrayToBase64Url } from "../Type.ts";
21
21
  import { isPromiseLike, type Awaitable } from "../Types.ts";
22
22
  import {
23
23
  OwnerId,
@@ -246,7 +246,9 @@ export const createRelaySqliteStorage =
246
246
  (sum, m) => sum + m.change.length,
247
247
  0,
248
248
  );
249
- const newStoredBytes = PositiveInt.orThrow(
249
+ // A sum of lengths can be zero. Throwing here would panic the
250
+ // relay's shared Run.
251
+ const newStoredBytes = NonNegativeInt.orThrow(
250
252
  (usage.storedBytes ?? 0) + incomingBytes,
251
253
  );
252
254
 
@@ -34,6 +34,7 @@ import {
34
34
  IdBytes,
35
35
  type InferType,
36
36
  NonEmptyTrimmedString100,
37
+ type NonEmptyTrimmedString1000,
37
38
  Null,
38
39
  nullOr,
39
40
  object,
@@ -42,6 +43,7 @@ import {
42
43
  type withDefault,
43
44
  } from "../Type.ts";
44
45
  import type { CompileTimeError, Simplify } from "../Types.ts";
46
+ import type { Evolu, maxMutationSize } from "./Evolu.ts";
45
47
  import type { AppOwner, OwnerIdBytes } from "./Owner.ts";
46
48
  import {
47
49
  OwnerEncryptionKey,
@@ -475,8 +477,17 @@ export type MutationKind = "insert" | "update" | "upsert";
475
477
  * inadvertently generate a large volume of CRDT messages. Each mutation
476
478
  * produces exactly one {@link CrdtMessage} containing all provided columns.
477
479
  *
478
- * Mutations never fail — values are already validated by the caller, and
479
- * changes are stored locally in SQLite.
480
+ * Each mutation must fit within {@link maxMutationSize}. Give every column a
481
+ * Type with a maximum length, such as {@link NonEmptyTrimmedString1000} or
482
+ * `maxLength(100_000)(Uint8Array)`, so that a table's largest values add up to
483
+ * less than the limit and input that is too large is rejected where it enters
484
+ * the app. A larger mutation throws before anything is saved, so the code after
485
+ * it does not run. Check unbounded input with {@link Evolu.getMutationSize}.
486
+ * Large binary data, such as images or videos, does not belong in a single
487
+ * mutation; a chunked API for it is planned.
488
+ *
489
+ * Binary values are copied when the mutation is made, so later changes to a
490
+ * `Uint8Array` do not change what is saved.
480
491
  *
481
492
  * - **insert**: all non-nullable columns required, nullable columns optional,
482
493
  * `id` omitted (auto-generated)
@@ -9,7 +9,7 @@ import type { NonEmptyReadonlyArray } from "../Array.ts";
9
9
  import { firstInArray, isNonEmptyArray } from "../Array.ts";
10
10
  import { assert, assertNonNullable } from "../Assert.ts";
11
11
  import type { Brand } from "../Brand.ts";
12
- import { concatBytes } from "../Bytes.ts";
12
+ import { concatByteArrays } from "../Bytes.ts";
13
13
  import type { DecryptWithXChaCha20Poly1305Error } from "../Crypto.ts";
14
14
  import { decrement } from "../Number.ts";
15
15
  import type { RandomDep } from "../Random.ts";
@@ -109,7 +109,7 @@ export interface StorageConfig {
109
109
  */
110
110
  readonly isOwnerWithinQuota: (
111
111
  ownerId: OwnerId,
112
- requiredBytes: PositiveInt,
112
+ requiredBytes: NonNegativeInt,
113
113
  ) => Awaitable<boolean>;
114
114
  }
115
115
 
@@ -581,7 +581,9 @@ export const createBaseSqliteStorage = (
581
581
  },
582
582
 
583
583
  getExistingTimestamps: (ownerIdBytes, timestampsBytes) => {
584
- const concatenatedTimestamps = concatBytes(...timestampsBytes);
584
+ // A batch can hold hundreds of thousands of timestamps, too many to spread
585
+ // into concatBytes.
586
+ const concatenatedTimestamps = concatByteArrays(timestampsBytes);
585
587
 
586
588
  const result = deps.sqlite.exec<{
587
589
  timestampBytes: TimestampBytes;
@@ -1808,7 +1810,7 @@ export const updateOwnerUsage =
1808
1810
  (deps: SqliteDep) =>
1809
1811
  (
1810
1812
  ownerIdBytes: OwnerIdBytes,
1811
- storedBytes: PositiveInt,
1813
+ storedBytes: NonNegativeInt,
1812
1814
  firstTimestamp: TimestampBytes,
1813
1815
  lastTimestamp: TimestampBytes,
1814
1816
  ): void => {