@evolu/common 6.0.1-preview.34 → 6.0.1-preview.35
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/Evolu/Evolu.d.ts +3 -3
- package/dist/src/Evolu/Evolu.d.ts.map +1 -1
- package/dist/src/Evolu/Evolu.js +3 -3
- package/dist/src/Evolu/Protocol.d.ts +3 -1
- package/dist/src/Evolu/Protocol.d.ts.map +1 -1
- package/dist/src/Evolu/Protocol.js +16 -5
- package/dist/src/Evolu/PublicKysely.d.ts.map +1 -1
- package/dist/src/Evolu/PublicKysely.js +0 -1
- package/dist/src/Evolu/Schema.d.ts +5 -3
- package/dist/src/Evolu/Schema.d.ts.map +1 -1
- package/dist/src/Evolu/Schema.js +20 -24
- package/dist/src/Evolu/Storage.d.ts +1 -0
- package/dist/src/Evolu/Storage.d.ts.map +1 -1
- package/dist/src/Evolu/Storage.js +9 -5
- package/dist/src/Evolu/Sync.d.ts +2 -2
- package/dist/src/Evolu/Sync.d.ts.map +1 -1
- package/dist/src/Evolu/Sync.js +43 -27
- package/dist/src/Sqlite.d.ts +29 -3
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +29 -3
- package/dist/src/Type.d.ts +38 -33
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +59 -40
- package/package.json +1 -1
- package/src/Evolu/Evolu.ts +12 -6
- package/src/Evolu/Protocol.ts +19 -7
- package/src/Evolu/PublicKysely.ts +1 -2
- package/src/Evolu/Schema.ts +31 -39
- package/src/Evolu/Storage.ts +8 -9
- package/src/Evolu/Sync.ts +62 -30
- package/src/Sqlite.ts +33 -3
- package/src/Type.ts +67 -45
package/src/Evolu/Protocol.ts
CHANGED
|
@@ -170,9 +170,11 @@
|
|
|
170
170
|
* initiator/non-initiator terminology instead, and consolidate into a single
|
|
171
171
|
* `applyProtocolMessage` function with conditional arguments to reduce code
|
|
172
172
|
* duplication.
|
|
173
|
-
* - ProtocolQuotaError should return storedBytes and actual quota.
|
|
174
173
|
* - Replace try-catch with Result + new Error (to preserve stacktraces). Measure
|
|
175
174
|
* Result overhead, it should be super small.
|
|
175
|
+
* - Allow clients to broadcast messages that are not persisted by relays. This
|
|
176
|
+
* would enable real-time ephemeral data (like cursor positions, typing
|
|
177
|
+
* indicators) to be forwarded by relays without storage overhead.
|
|
176
178
|
*/
|
|
177
179
|
|
|
178
180
|
import { Packr } from "msgpackr";
|
|
@@ -416,7 +418,9 @@ export interface ProtocolWriteError extends BaseOwnerError {
|
|
|
416
418
|
* excess local data is affected. Other devices that haven't exceeded quota can
|
|
417
419
|
* still sync normally.
|
|
418
420
|
*
|
|
419
|
-
* Clients should prompt the user to upgrade their
|
|
421
|
+
* Clients should prompt the user to contact the relay provider or upgrade their
|
|
422
|
+
* plan. Quota monitoring and management is the relay provider's
|
|
423
|
+
* responsibility.
|
|
420
424
|
*/
|
|
421
425
|
export interface ProtocolQuotaError extends BaseOwnerError {
|
|
422
426
|
readonly type: "ProtocolQuotaError";
|
|
@@ -1727,7 +1731,11 @@ export const encodeAndEncryptDbChange =
|
|
|
1727
1731
|
// assigning this EncryptedDbChange to a different EncryptedCrdtMessage)
|
|
1728
1732
|
buffer.extend(timestampToTimestampBytes(message.timestamp));
|
|
1729
1733
|
|
|
1730
|
-
encodeFlags(buffer, [
|
|
1734
|
+
encodeFlags(buffer, [
|
|
1735
|
+
message.change.isInsert,
|
|
1736
|
+
message.change.isDelete != null,
|
|
1737
|
+
message.change.isDelete ?? false,
|
|
1738
|
+
]);
|
|
1731
1739
|
|
|
1732
1740
|
encodeString(buffer, message.change.table);
|
|
1733
1741
|
buffer.extend(idToIdBytes(message.change.id));
|
|
@@ -1803,9 +1811,7 @@ export const decryptAndDecodeDbChange =
|
|
|
1803
1811
|
});
|
|
1804
1812
|
}
|
|
1805
1813
|
|
|
1806
|
-
const flags = decodeFlags(buffer, PositiveInt.orThrow(
|
|
1807
|
-
const isInsert = flags[0];
|
|
1808
|
-
|
|
1814
|
+
const flags = decodeFlags(buffer, PositiveInt.orThrow(3));
|
|
1809
1815
|
const table = decodeString(buffer);
|
|
1810
1816
|
const id = decodeId(buffer);
|
|
1811
1817
|
|
|
@@ -1818,7 +1824,13 @@ export const decryptAndDecodeDbChange =
|
|
|
1818
1824
|
values[column] = value;
|
|
1819
1825
|
}
|
|
1820
1826
|
|
|
1821
|
-
const dbChange = DbChange.orThrow({
|
|
1827
|
+
const dbChange = DbChange.orThrow({
|
|
1828
|
+
table,
|
|
1829
|
+
id,
|
|
1830
|
+
values,
|
|
1831
|
+
isInsert: flags[0],
|
|
1832
|
+
isDelete: flags[1] ? flags[2] : null,
|
|
1833
|
+
});
|
|
1822
1834
|
|
|
1823
1835
|
return ok(dbChange);
|
|
1824
1836
|
} catch (error) {
|
|
@@ -209,8 +209,7 @@ export function getJsonObjectArgs(
|
|
|
209
209
|
table: string,
|
|
210
210
|
): Array<Expression<unknown> | string> {
|
|
211
211
|
const args: Array<Expression<unknown> | string> = [];
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
|
|
214
213
|
for (const { selection: s } of node.selections ?? []) {
|
|
215
214
|
if (ReferenceNode.is(s) && ColumnNode.is(s.column)) {
|
|
216
215
|
args.push(
|
package/src/Evolu/Schema.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import * as Kysely from "kysely";
|
|
2
|
-
import { assert } from "../Assert.js";
|
|
3
|
-
import { createEqArrayLike, eqString } from "../Eq.js";
|
|
4
2
|
import { mapObject, objectToEntries, ReadonlyRecord } from "../Object.js";
|
|
5
3
|
import { ok, Result } from "../Result.js";
|
|
6
4
|
import {
|
|
@@ -101,11 +99,11 @@ export type EvoluSchema = ReadonlyRecord<
|
|
|
101
99
|
export type ValidateSchema<S extends EvoluSchema> =
|
|
102
100
|
ValidateSchemaHasId<S> extends never
|
|
103
101
|
? ValidateIdColumnType<S> extends never
|
|
104
|
-
?
|
|
102
|
+
? ValidateNoSystemColumns<S> extends never
|
|
105
103
|
? ValidateColumnTypes<S> extends never
|
|
106
104
|
? S
|
|
107
105
|
: ValidateColumnTypes<S>
|
|
108
|
-
:
|
|
106
|
+
: ValidateNoSystemColumns<S>
|
|
109
107
|
: ValidateIdColumnType<S>
|
|
110
108
|
: ValidateSchemaHasId<S>;
|
|
111
109
|
|
|
@@ -129,13 +127,17 @@ export type ValidateIdColumnType<S extends EvoluSchema> =
|
|
|
129
127
|
: never
|
|
130
128
|
: never;
|
|
131
129
|
|
|
132
|
-
export type
|
|
130
|
+
export type ValidateNoSystemColumns<S extends EvoluSchema> =
|
|
133
131
|
keyof S extends infer TableName
|
|
134
132
|
? TableName extends keyof S
|
|
135
133
|
? keyof S[TableName] extends infer ColumnName
|
|
136
134
|
? ColumnName extends keyof S[TableName]
|
|
137
|
-
? ColumnName extends
|
|
138
|
-
|
|
135
|
+
? ColumnName extends
|
|
136
|
+
| "createdAt"
|
|
137
|
+
| "updatedAt"
|
|
138
|
+
| "isDeleted"
|
|
139
|
+
| "ownerId"
|
|
140
|
+
? SchemaValidationError<`Table "${TableName & string}" uses system column name "${ColumnName & string}". System columns (createdAt, updatedAt, isDeleted, ownerId) are added automatically.`>
|
|
139
141
|
: never
|
|
140
142
|
: never
|
|
141
143
|
: never
|
|
@@ -192,10 +194,7 @@ export type CreateQuery<S extends EvoluSchema> = <R extends Row>(
|
|
|
192
194
|
Kysely.Kysely<
|
|
193
195
|
{
|
|
194
196
|
[Table in keyof S]: {
|
|
195
|
-
readonly [Column in keyof S[Table]]: Column extends
|
|
196
|
-
| "id"
|
|
197
|
-
| "createdAt"
|
|
198
|
-
| "updatedAt"
|
|
197
|
+
readonly [Column in keyof S[Table]]: Column extends "id"
|
|
199
198
|
? InferType<S[Table][Column]>
|
|
200
199
|
: InferType<S[Table][Column]> | null;
|
|
201
200
|
} & SystemColumns;
|
|
@@ -222,24 +221,17 @@ export type CreateQuery<S extends EvoluSchema> = <R extends Row>(
|
|
|
222
221
|
* - `updatedAt`: Set by Evolu on every row change, derived from {@link Timestamp}.
|
|
223
222
|
* - `isDeleted`: Soft delete flag created by Evolu and used by the developer to
|
|
224
223
|
* mark rows as deleted.
|
|
224
|
+
* - `ownerId`: Represents ownership and logically partitions the database.
|
|
225
225
|
*/
|
|
226
226
|
export const SystemColumns = object({
|
|
227
227
|
createdAt: DateIso,
|
|
228
228
|
updatedAt: DateIso,
|
|
229
229
|
isDeleted: nullOr(SqliteBoolean),
|
|
230
|
-
|
|
230
|
+
ownerId: OwnerId,
|
|
231
231
|
});
|
|
232
232
|
export type SystemColumns = typeof SystemColumns.Type;
|
|
233
233
|
|
|
234
|
-
export const systemColumns =
|
|
235
|
-
|
|
236
|
-
assert(
|
|
237
|
-
createEqArrayLike(eqString)(
|
|
238
|
-
objectToEntries(SystemColumns.props).map(([key]) => key),
|
|
239
|
-
systemColumns,
|
|
240
|
-
),
|
|
241
|
-
"SystemColumns keys must match systemColumnsKeys",
|
|
242
|
-
);
|
|
234
|
+
export const systemColumns = Object.keys(SystemColumns.props);
|
|
243
235
|
|
|
244
236
|
export type MutationKind = "insert" | "update" | "upsert";
|
|
245
237
|
|
|
@@ -550,10 +542,7 @@ export const ensureDbSchema =
|
|
|
550
542
|
(t) => t.name === newTable.name,
|
|
551
543
|
);
|
|
552
544
|
if (!currentTable) {
|
|
553
|
-
queries.push(
|
|
554
|
-
sql: createAppTableWithDefaultColumns(newTable),
|
|
555
|
-
parameters: [],
|
|
556
|
-
});
|
|
545
|
+
queries.push(createAppTable(newTable));
|
|
557
546
|
} else {
|
|
558
547
|
newTable.columns
|
|
559
548
|
.filter((newColumn) => !currentTable.columns.includes(newColumn))
|
|
@@ -599,21 +588,24 @@ export const ensureDbSchema =
|
|
|
599
588
|
return ok();
|
|
600
589
|
};
|
|
601
590
|
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
.concat(
|
|
591
|
+
const createAppTable = (table: DbTable) => sql`
|
|
592
|
+
create table ${sql.identifier(table.name)} (
|
|
593
|
+
"id" text,
|
|
594
|
+
${sql.raw(
|
|
595
|
+
`${systemColumns
|
|
596
|
+
.concat(table.columns)
|
|
608
597
|
.filter((c) => c !== "id")
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
.map((name) => `${sql.identifier(name).sql}
|
|
613
|
-
.join(", ")}
|
|
614
|
-
)
|
|
615
|
-
|
|
616
|
-
|
|
598
|
+
// With strict tables and any type, data is preserved exactly as received
|
|
599
|
+
// without any type affinity coercion. This allows storing any data type
|
|
600
|
+
// while maintaining strict null enforcement for primary key columns.
|
|
601
|
+
.map((name) => `${sql.identifier(name).sql} any`)
|
|
602
|
+
.join(", ")}`,
|
|
603
|
+
)},
|
|
604
|
+
primary key ("ownerId", "id")
|
|
605
|
+
)
|
|
606
|
+
without rowid, strict;
|
|
607
|
+
`;
|
|
608
|
+
|
|
617
609
|
// https://kysely.dev/docs/recipes/splitting-query-building-and-execution
|
|
618
610
|
export const kysely = new Kysely.Kysely({
|
|
619
611
|
dialect: {
|
package/src/Evolu/Storage.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
2
2
|
import {
|
|
3
|
-
filterArray,
|
|
4
3
|
firstInArray,
|
|
5
4
|
isNonEmptyReadonlyArray,
|
|
6
5
|
NonEmptyReadonlyArray,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
Id,
|
|
20
19
|
Int64String,
|
|
21
20
|
NonNegativeInt,
|
|
21
|
+
nullOr,
|
|
22
22
|
object,
|
|
23
23
|
PositiveInt,
|
|
24
24
|
record,
|
|
@@ -32,8 +32,8 @@ import {
|
|
|
32
32
|
OwnerIdBytes,
|
|
33
33
|
OwnerWriteKey,
|
|
34
34
|
} from "./Owner.js";
|
|
35
|
-
import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
|
|
36
35
|
import { systemColumns } from "./Schema.js";
|
|
36
|
+
import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
|
|
37
37
|
|
|
38
38
|
export interface StorageConfig {
|
|
39
39
|
/**
|
|
@@ -256,18 +256,16 @@ export interface CrdtMessage {
|
|
|
256
256
|
export const DbChangeValues = record(String, SqliteValue);
|
|
257
257
|
export type DbChangeValues = typeof DbChangeValues.Type;
|
|
258
258
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
);
|
|
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
263
|
|
|
264
264
|
export const ValidDbChangeValues = brand(
|
|
265
265
|
"ValidDbChangeValues",
|
|
266
266
|
DbChangeValues,
|
|
267
267
|
(value) => {
|
|
268
|
-
const invalidColumns =
|
|
269
|
-
(key) => key in value,
|
|
270
|
-
);
|
|
268
|
+
const invalidColumns = forbiddenSystemColumns.filter((key) => key in value);
|
|
271
269
|
if (invalidColumns.length > 0)
|
|
272
270
|
return err<ValidDbChangeValuesError>({
|
|
273
271
|
type: "ValidDbChangeValues",
|
|
@@ -294,6 +292,7 @@ export const DbChange = object({
|
|
|
294
292
|
id: Id,
|
|
295
293
|
values: ValidDbChangeValues,
|
|
296
294
|
isInsert: Boolean,
|
|
295
|
+
isDelete: nullOr(Boolean),
|
|
297
296
|
});
|
|
298
297
|
export type DbChange = typeof DbChange.Type;
|
|
299
298
|
|
package/src/Evolu/Sync.ts
CHANGED
|
@@ -19,7 +19,16 @@ import { objectToEntries } from "../Object.js";
|
|
|
19
19
|
import { RandomDep } from "../Random.js";
|
|
20
20
|
import { createResources } from "../Resources.js";
|
|
21
21
|
import { err, ok, Result } from "../Result.js";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
booleanToSqliteBoolean,
|
|
24
|
+
sql,
|
|
25
|
+
SqliteBoolean,
|
|
26
|
+
sqliteBooleanToBoolean,
|
|
27
|
+
SqliteDep,
|
|
28
|
+
SqliteError,
|
|
29
|
+
sqliteFalse,
|
|
30
|
+
SqliteValue,
|
|
31
|
+
} from "../Sqlite.js";
|
|
23
32
|
import { AbortError, createMutex } from "../Task.js";
|
|
24
33
|
import { TimeDep } from "../Time.js";
|
|
25
34
|
import {
|
|
@@ -30,7 +39,7 @@ import {
|
|
|
30
39
|
PositiveInt,
|
|
31
40
|
} from "../Type.js";
|
|
32
41
|
import { CreateWebSocketDep, WebSocket } from "../WebSocket.js";
|
|
33
|
-
import type { PostMessageDep } from "./Db.js";
|
|
42
|
+
import type { AppOwnerDep, PostMessageDep } from "./Db.js";
|
|
34
43
|
import {
|
|
35
44
|
AppOwner,
|
|
36
45
|
OwnerEncryptionKey,
|
|
@@ -596,14 +605,24 @@ const createClientStorage =
|
|
|
596
605
|
const { table, id } = rows[0];
|
|
597
606
|
const values: Record<string, SqliteValue> = {};
|
|
598
607
|
let isInsert = false;
|
|
608
|
+
let isDelete: boolean | null = null;
|
|
599
609
|
|
|
600
610
|
for (const r of rows) {
|
|
601
611
|
assert(r.table === table, "All rows must have the same table");
|
|
602
612
|
assert(eqArrayNumber(r.id, id), "All rows must have the same Id");
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
613
|
+
switch (r.column) {
|
|
614
|
+
case "createdAt":
|
|
615
|
+
isInsert = true;
|
|
616
|
+
break;
|
|
617
|
+
case "isDeleted":
|
|
618
|
+
assert(
|
|
619
|
+
SqliteBoolean.is(r.value),
|
|
620
|
+
"isDeleted column must contain a valid SqliteBoolean (0 or 1)",
|
|
621
|
+
);
|
|
622
|
+
isDelete = sqliteBooleanToBoolean(r.value);
|
|
623
|
+
break;
|
|
624
|
+
default:
|
|
625
|
+
values[r.column] = r.value;
|
|
607
626
|
}
|
|
608
627
|
}
|
|
609
628
|
|
|
@@ -614,6 +633,7 @@ const createClientStorage =
|
|
|
614
633
|
id: idBytesToId(rows[0].id),
|
|
615
634
|
values,
|
|
616
635
|
isInsert,
|
|
636
|
+
isDelete,
|
|
617
637
|
}),
|
|
618
638
|
};
|
|
619
639
|
|
|
@@ -632,12 +652,9 @@ const createTransportKey = (transportConfig: OwnerTransport): TransportKey => {
|
|
|
632
652
|
};
|
|
633
653
|
|
|
634
654
|
export const applyLocalOnlyChange =
|
|
635
|
-
(deps: SqliteDep & TimeDep) =>
|
|
655
|
+
(deps: SqliteDep & TimeDep & AppOwnerDep) =>
|
|
636
656
|
(change: MutationChange): Result<void, SqliteError> => {
|
|
637
|
-
|
|
638
|
-
"isDeleted" in change.values && change.values.isDeleted === 1;
|
|
639
|
-
|
|
640
|
-
if (isDeletion) {
|
|
657
|
+
if (change.isDelete) {
|
|
641
658
|
const result = deps.sqlite.exec(sql`
|
|
642
659
|
delete from ${sql.identifier(change.table)}
|
|
643
660
|
where id = ${change.id};
|
|
@@ -645,13 +662,19 @@ export const applyLocalOnlyChange =
|
|
|
645
662
|
if (!result.ok) return result;
|
|
646
663
|
} else {
|
|
647
664
|
const now = deps.time.nowIso();
|
|
665
|
+
const ownerId = deps.appOwner.id;
|
|
666
|
+
|
|
667
|
+
let entries = objectToEntries(change.values);
|
|
668
|
+
if (change.isDelete !== null) {
|
|
669
|
+
entries = [...entries, ["isDeleted", sqliteFalse]];
|
|
670
|
+
}
|
|
648
671
|
|
|
649
|
-
for (const [column, value] of
|
|
672
|
+
for (const [column, value] of entries) {
|
|
650
673
|
const result = deps.sqlite.exec(sql.prepared`
|
|
651
674
|
insert into ${sql.identifier(change.table)}
|
|
652
|
-
("id", ${sql.identifier(column)}, createdAt, updatedAt)
|
|
653
|
-
values (${change.id}, ${value}, ${now}, ${now})
|
|
654
|
-
on conflict ("id") do update
|
|
675
|
+
("ownerId", "id", ${sql.identifier(column)}, createdAt, updatedAt)
|
|
676
|
+
values (${ownerId}, ${change.id}, ${value}, ${now}, ${now})
|
|
677
|
+
on conflict ("ownerId", "id") do update
|
|
655
678
|
set
|
|
656
679
|
${sql.identifier(column)} = ${value},
|
|
657
680
|
updatedAt = ${now};
|
|
@@ -721,23 +744,21 @@ const applyMessages =
|
|
|
721
744
|
const applyMessageToAppTable =
|
|
722
745
|
(deps: SqliteDep) =>
|
|
723
746
|
(
|
|
724
|
-
|
|
747
|
+
ownerIdBytes: OwnerIdBytes,
|
|
725
748
|
message: CrdtMessage,
|
|
726
749
|
date: DateIso,
|
|
727
750
|
): Result<void, SqliteError> => {
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
entries = [...entries, ["createdAt", date]];
|
|
731
|
-
}
|
|
751
|
+
const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
|
|
752
|
+
const columns = dbChangeToColumns(message.change, date);
|
|
732
753
|
|
|
733
|
-
for (const [column, value] of
|
|
754
|
+
for (const [column, value] of columns) {
|
|
734
755
|
const result = deps.sqlite.exec(sql.prepared`
|
|
735
756
|
with
|
|
736
757
|
existingTimestamp as (
|
|
737
758
|
select 1
|
|
738
759
|
from evolu_history
|
|
739
760
|
where
|
|
740
|
-
"ownerId" = ${
|
|
761
|
+
"ownerId" = ${ownerIdBytes}
|
|
741
762
|
and "table" = ${message.change.table}
|
|
742
763
|
and "id" = ${idToIdBytes(message.change.id)}
|
|
743
764
|
and "column" = ${column}
|
|
@@ -745,10 +766,10 @@ const applyMessageToAppTable =
|
|
|
745
766
|
limit 1
|
|
746
767
|
)
|
|
747
768
|
insert into ${sql.identifier(message.change.table)}
|
|
748
|
-
("id", ${sql.identifier(column)}, updatedAt)
|
|
749
|
-
select ${message.change.id}, ${value}, ${date}
|
|
769
|
+
("ownerId", "id", ${sql.identifier(column)}, updatedAt)
|
|
770
|
+
select ${ownerId}, ${message.change.id}, ${value}, ${date}
|
|
750
771
|
where not exists (select 1 from existingTimestamp)
|
|
751
|
-
on conflict ("id") do update
|
|
772
|
+
on conflict ("ownerId", "id") do update
|
|
752
773
|
set
|
|
753
774
|
${sql.identifier(column)} = ${value},
|
|
754
775
|
updatedAt = ${date}
|
|
@@ -775,12 +796,9 @@ export const applyMessageToTimestampAndHistoryTables =
|
|
|
775
796
|
const result = deps.storage.insertTimestamp(ownerId, timestamp, strategy);
|
|
776
797
|
if (!result.ok) return result;
|
|
777
798
|
|
|
778
|
-
|
|
779
|
-
if (message.change.isInsert) {
|
|
780
|
-
entries = [...entries, ["createdAt", date]];
|
|
781
|
-
}
|
|
799
|
+
const columns = dbChangeToColumns(message.change, date);
|
|
782
800
|
|
|
783
|
-
for (const [column, value] of
|
|
801
|
+
for (const [column, value] of columns) {
|
|
784
802
|
const result = deps.sqlite.exec(sql.prepared`
|
|
785
803
|
insert into evolu_history
|
|
786
804
|
("ownerId", "table", "id", "column", "value", "timestamp")
|
|
@@ -801,6 +819,20 @@ export const applyMessageToTimestampAndHistoryTables =
|
|
|
801
819
|
return ok();
|
|
802
820
|
};
|
|
803
821
|
|
|
822
|
+
const dbChangeToColumns = (
|
|
823
|
+
change: DbChange,
|
|
824
|
+
date: DateIso,
|
|
825
|
+
): Array<[string, SqliteValue | DateIso]> => {
|
|
826
|
+
const entries = [...objectToEntries(change.values)];
|
|
827
|
+
if (change.isInsert) {
|
|
828
|
+
entries.push(["createdAt", date]);
|
|
829
|
+
}
|
|
830
|
+
if (change.isDelete !== null) {
|
|
831
|
+
entries.push(["isDeleted", booleanToSqliteBoolean(change.isDelete)]);
|
|
832
|
+
}
|
|
833
|
+
return entries;
|
|
834
|
+
};
|
|
835
|
+
|
|
804
836
|
/**
|
|
805
837
|
* TODO: Rework for the new owners API.
|
|
806
838
|
*
|
package/src/Sqlite.ts
CHANGED
|
@@ -511,10 +511,14 @@ const drawSqliteQueryPlan = (rows: Array<SqliteQueryPlanRow>): string =>
|
|
|
511
511
|
* SQLite represents boolean values using `0` (false) and `1` (true) instead of
|
|
512
512
|
* a dedicated boolean type.
|
|
513
513
|
*
|
|
514
|
-
* Use {@link sqliteTrue} and {@link sqliteFalse} constants for better
|
|
515
|
-
* readability.
|
|
516
|
-
*
|
|
517
514
|
* See: https://www.sqlite.org/quirks.html#no_separate_boolean_datatype
|
|
515
|
+
*
|
|
516
|
+
* ### Tips
|
|
517
|
+
*
|
|
518
|
+
* - Use {@link sqliteTrue} and {@link sqliteFalse} constants for better
|
|
519
|
+
* readability.
|
|
520
|
+
* - Use {@link booleanToSqliteBoolean} and {@link sqliteBooleanToBoolean} for
|
|
521
|
+
* converting between JavaScript booleans and SQLite boolean values.
|
|
518
522
|
*/
|
|
519
523
|
export const SqliteBoolean = union(0, 1);
|
|
520
524
|
export type SqliteBoolean = typeof SqliteBoolean.Type;
|
|
@@ -532,3 +536,29 @@ export const sqliteTrue = 1;
|
|
|
532
536
|
* See {@link SqliteBoolean}.
|
|
533
537
|
*/
|
|
534
538
|
export const sqliteFalse = 0;
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Converts a JavaScript boolean to a {@link SqliteBoolean}.
|
|
542
|
+
*
|
|
543
|
+
* ### Example
|
|
544
|
+
*
|
|
545
|
+
* ```ts
|
|
546
|
+
* const isActive = true;
|
|
547
|
+
* const sqlValue = booleanToSqliteBoolean(isActive); // Returns 1
|
|
548
|
+
* ```
|
|
549
|
+
*/
|
|
550
|
+
export const booleanToSqliteBoolean = (value: boolean): SqliteBoolean =>
|
|
551
|
+
value ? sqliteTrue : sqliteFalse;
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Converts a {@link SqliteBoolean} to a JavaScript boolean.
|
|
555
|
+
*
|
|
556
|
+
* ### Example
|
|
557
|
+
*
|
|
558
|
+
* ```ts
|
|
559
|
+
* const sqlValue: SqliteBoolean = 1;
|
|
560
|
+
* const bool = sqliteBooleanToBoolean(sqlValue); // Returns true
|
|
561
|
+
* ```
|
|
562
|
+
*/
|
|
563
|
+
export const sqliteBooleanToBoolean = (value: SqliteBoolean): boolean =>
|
|
564
|
+
value === sqliteTrue;
|
package/src/Type.ts
CHANGED
|
@@ -212,6 +212,7 @@ import { isPlainObject } from "./Object.js";
|
|
|
212
212
|
import { hasNodeBuffer } from "./Platform.js";
|
|
213
213
|
import { err, getOrNull, getOrThrow, ok, Result, trySync } from "./Result.js";
|
|
214
214
|
import { safelyStringifyUnknownValue } from "./String.js";
|
|
215
|
+
import type { TimeDep } from "./Time.js";
|
|
215
216
|
import type { Literal, Simplify, WidenLiteral } from "./Types.js";
|
|
216
217
|
import { IntentionalNever } from "./Types.js";
|
|
217
218
|
|
|
@@ -1589,41 +1590,27 @@ export const formatSimplePasswordError = (
|
|
|
1589
1590
|
);
|
|
1590
1591
|
|
|
1591
1592
|
/**
|
|
1592
|
-
*
|
|
1593
|
-
*
|
|
1594
|
-
*
|
|
1595
|
-
*
|
|
1596
|
-
*
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
1601
|
-
*
|
|
1602
|
-
*
|
|
1603
|
-
*
|
|
1604
|
-
*
|
|
1605
|
-
*
|
|
1606
|
-
*
|
|
1607
|
-
*
|
|
1608
|
-
*
|
|
1609
|
-
*
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
* characters), standard and native string serialization (Base64Url), and no
|
|
1614
|
-
* privacy leaks.
|
|
1615
|
-
*
|
|
1616
|
-
* ### Future Consideration
|
|
1617
|
-
*
|
|
1618
|
-
* For database-heavy workloads where insert performance is critical, a hybrid
|
|
1619
|
-
* approach could be considered: `timestamp ^ H(cluster_id, timestamp >> N)`
|
|
1620
|
-
* where H is a keyed hash function and N is a configurable parameter. This
|
|
1621
|
-
* would maintain spatial locality for database caches (improving insert
|
|
1622
|
-
* performance by an order of magnitude) while adding entropy to prevent
|
|
1623
|
-
* timestamp leakage and correlation across systems. The parameter N would allow
|
|
1624
|
-
* trading off cache locality (larger N = better locality) versus entropy
|
|
1625
|
-
* distribution. See https://brooker.co.za/blog/2025/10/22/uuidv7.html for
|
|
1626
|
-
* details on this approach.
|
|
1593
|
+
* Evolu Id: 16 bytes encoded as a 22‑character Base64Url string.
|
|
1594
|
+
*
|
|
1595
|
+
* There are three ways to create an Evolu Id:
|
|
1596
|
+
*
|
|
1597
|
+
* - {@link createId} – default cryptographically secure random bytes
|
|
1598
|
+
* (privacy‑preserving)
|
|
1599
|
+
* - {@link createIdFromString} – deterministic: first 16 bytes of SHA‑256 of a
|
|
1600
|
+
* string
|
|
1601
|
+
* - {@link createIdAsUuidv7} – optional: embeds timestamp bits (UUID v7 layout)
|
|
1602
|
+
*
|
|
1603
|
+
* Privacy: the default random Id does not leak creation time and is safe to
|
|
1604
|
+
* share or log. The UUID v7 variant leaks creation time anywhere the Id is
|
|
1605
|
+
* copied (logs, URLs, exports); only use it when you explicitly want insertion
|
|
1606
|
+
* locality for very large write‑heavy tables and accept timestamp exposure.
|
|
1607
|
+
*
|
|
1608
|
+
* ### Future
|
|
1609
|
+
*
|
|
1610
|
+
* A possible hybrid masked‑time approach (`timestamp ^ H(cluster_id, timestamp
|
|
1611
|
+
*
|
|
1612
|
+
* > > N)`) could provide locality without exposing raw creation time. See
|
|
1613
|
+
* > > https://brooker.co.za/blog/2025/10/22/uuidv7.html
|
|
1627
1614
|
*
|
|
1628
1615
|
* @category String
|
|
1629
1616
|
*/
|
|
@@ -1641,26 +1628,25 @@ export const formatIdError = createTypeErrorFormatter<IdError>(
|
|
|
1641
1628
|
);
|
|
1642
1629
|
|
|
1643
1630
|
/**
|
|
1644
|
-
* Creates
|
|
1631
|
+
* Creates a random {@link Id}. This is the recommended default.
|
|
1632
|
+
*
|
|
1633
|
+
* Use {@link createIdFromString} for deterministic mapping of external IDs or
|
|
1634
|
+
* {@link createIdAsUuidv7} when you accept timestamp leakage for index
|
|
1635
|
+
* locality.
|
|
1645
1636
|
*
|
|
1646
1637
|
* ### Example
|
|
1647
1638
|
*
|
|
1648
1639
|
* ```ts
|
|
1649
|
-
* // string & Brand<"Id">
|
|
1650
1640
|
* const id = createId(deps);
|
|
1651
|
-
*
|
|
1652
|
-
* // string & Brand<"Id"> & Brand<"Todo">
|
|
1653
1641
|
* const todoId = createId<"Todo">(deps);
|
|
1654
1642
|
* ```
|
|
1655
1643
|
*/
|
|
1656
1644
|
export const createId = <B extends string = never>(
|
|
1657
1645
|
deps: RandomBytesDep,
|
|
1658
|
-
): [B] extends [never] ? Id : Id & Brand<B> =>
|
|
1659
|
-
uint8ArrayToBase64Url(deps.randomBytes.create(16))
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
? Id
|
|
1663
|
-
: Id & Brand<B>;
|
|
1646
|
+
): [B] extends [never] ? Id : Id & Brand<B> => {
|
|
1647
|
+
const id = uint8ArrayToBase64Url(deps.randomBytes.create(16));
|
|
1648
|
+
return id as unknown as [B] extends [never] ? Id : Id & Brand<B>;
|
|
1649
|
+
};
|
|
1664
1650
|
|
|
1665
1651
|
/**
|
|
1666
1652
|
* Creates an {@link Id} from a string using SHA-256.
|
|
@@ -1704,6 +1690,42 @@ export const createIdFromString = <B extends string = never>(
|
|
|
1704
1690
|
return id as [B] extends [never] ? Id : Id & Brand<B>;
|
|
1705
1691
|
};
|
|
1706
1692
|
|
|
1693
|
+
/**
|
|
1694
|
+
* Creates an {@link Id} embedding timestamp bits (UUID v7 layout) before
|
|
1695
|
+
* Base64Url encoding.
|
|
1696
|
+
*
|
|
1697
|
+
* Tradeoff: better insertion locality / index performance for huge datasets vs
|
|
1698
|
+
* leaking creation time everywhere the Id appears. Evolu uses {@link createId}
|
|
1699
|
+
* by default to avoid activity leakage; choose this only if you explicitly
|
|
1700
|
+
* accept timestamp exposure.
|
|
1701
|
+
*
|
|
1702
|
+
* ### Example
|
|
1703
|
+
*
|
|
1704
|
+
* ```ts
|
|
1705
|
+
* const id = createIdAsUuidv7({ randomBytes, time });
|
|
1706
|
+
* const todoId = createIdAsUuidv7<"Todo">({ randomBytes, time });
|
|
1707
|
+
* ```
|
|
1708
|
+
*/
|
|
1709
|
+
export const createIdAsUuidv7 = <B extends string = never>(
|
|
1710
|
+
deps: RandomBytesDep & TimeDep,
|
|
1711
|
+
): [B] extends [never] ? Id : Id & Brand<B> => {
|
|
1712
|
+
const id = deps.randomBytes.create(16);
|
|
1713
|
+
|
|
1714
|
+
const timestamp = globalThis.BigInt(deps.time.now());
|
|
1715
|
+
|
|
1716
|
+
id[0] = globalThis.Number((timestamp >> 40n) & 0xffn);
|
|
1717
|
+
id[1] = globalThis.Number((timestamp >> 32n) & 0xffn);
|
|
1718
|
+
id[2] = globalThis.Number((timestamp >> 24n) & 0xffn);
|
|
1719
|
+
id[3] = globalThis.Number((timestamp >> 16n) & 0xffn);
|
|
1720
|
+
id[4] = globalThis.Number((timestamp >> 8n) & 0xffn);
|
|
1721
|
+
id[5] = globalThis.Number(timestamp & 0xffn);
|
|
1722
|
+
|
|
1723
|
+
id[6] = (id[6] & 0x0f) | 0x70;
|
|
1724
|
+
id[8] = (id[8] & 0x3f) | 0x80;
|
|
1725
|
+
|
|
1726
|
+
return id as unknown as [B] extends [never] ? Id : Id & Brand<B>;
|
|
1727
|
+
};
|
|
1728
|
+
|
|
1707
1729
|
/**
|
|
1708
1730
|
* Creates a branded {@link Id} Type for a table's primary key.
|
|
1709
1731
|
*
|