@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.
- package/dist/src/Bytes.d.ts +39 -2
- package/dist/src/Bytes.d.ts.map +1 -1
- package/dist/src/Bytes.js +50 -2
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/local-first/Evolu.d.ts +77 -3
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +79 -3
- package/dist/src/local-first/Protocol.d.ts +12 -3
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +44 -22
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +4 -2
- package/dist/src/local-first/Schema.d.ts +11 -2
- package/dist/src/local-first/Schema.d.ts.map +1 -1
- package/dist/src/local-first/Storage.d.ts +3 -3
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +4 -2
- package/package.json +1 -1
- package/src/Bytes.test.ts +27 -0
- package/src/Bytes.ts +58 -2
- package/src/index.ts +6 -1
- package/src/local-first/Evolu.test.ts +308 -1
- package/src/local-first/Evolu.ts +176 -5
- package/src/local-first/Protocol.test.ts +17 -0
- package/src/local-first/Protocol.ts +60 -38
- package/src/local-first/Relay.ts +4 -2
- package/src/local-first/Schema.ts +13 -2
- package/src/local-first/Storage.ts +6 -4
package/src/local-first/Evolu.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
107
|
-
* message
|
|
108
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
2073
|
-
|
|
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
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
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);
|
package/src/local-first/Relay.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
*
|
|
479
|
-
*
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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:
|
|
1813
|
+
storedBytes: NonNegativeInt,
|
|
1812
1814
|
firstTimestamp: TimestampBytes,
|
|
1813
1815
|
lastTimestamp: TimestampBytes,
|
|
1814
1816
|
): void => {
|