From 741d70f81e48bcdd54642bf174048293de3f8e2b Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Thu, 5 Feb 2026 11:17:45 +0100 Subject: [PATCH 01/37] Add build/test docs and architecture overview (cherry picked from commit 4a67e1466cb0f043771f2476a14ae01eac9557aa) --- .github/copilot-instructions.md | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 12b6fc92d..528e3e356 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -69,6 +69,43 @@ apps/ ## Evolu Project Guidelines +## Build and test + +```bash +pnpm install # Install dependencies (Node >=24.0.0) +pnpm build # Build all packages +pnpm dev # Watch mode for development +pnpm test # Run all tests +pnpm test:coverage # With coverage +pnpm lint # ESLint +pnpm format # Prettier +pnpm biome # Biome (catches import cycles) +pnpm verify # Full verification (lint + format + biome + test) +``` + +## Architecture + +Monorepo with pnpm workspaces and Turborepo. All packages depend on `@evolu/common`: + +- `@evolu/common` — Platform-independent core (Result, Task, Type, Brand, Crypto, Sqlite) +- `@evolu/web` — Web platform (SQLite WASM, SharedWorker) +- `@evolu/nodejs` — Node.js (better-sqlite3, ws) +- `@evolu/react-native` — React Native/Expo (expo-sqlite) +- `@evolu/react` — Platform-independent React +- `@evolu/react-web` — React + web combined +- `@evolu/svelte` — Svelte 5 +- `@evolu/vue` — Vue 3 + +Key directories: + +- `packages/common/src/` — Core utilities and abstractions +- `packages/common/src/local-first/` — Local-first subsystem (Db, Evolu, Query, Schema, Sync, Relay) +- `apps/web/` — Documentation website +- `apps/relay/` — Sync server (Docker-deployable) +- `examples/` — Framework-specific example apps + +--- + Follow these specific conventions and patterns: ## Code organization & imports From cc0e493cfec8488061bb9e9c9a84a214459eb1d6 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Thu, 5 Feb 2026 16:22:00 +0100 Subject: [PATCH 02/37] Rename test app owners (cherry picked from commit 82c941ad2b6803b5abfac239c53b276091d77c6e) --- packages/common/test/Crypto.test.ts | 4 +- .../common/test/local-first/Owner.test.ts | 22 ++-- .../common/test/local-first/Protocol.test.ts | 102 +++++++++--------- .../common/test/local-first/Relay.test.ts | 95 ++++++++-------- .../common/test/local-first/Storage.test.ts | 50 +++++---- packages/common/test/local-first/_fixtures.ts | 15 ++- 6 files changed, 150 insertions(+), 138 deletions(-) diff --git a/packages/common/test/Crypto.test.ts b/packages/common/test/Crypto.test.ts index 58c2c80c5..8e67380af 100644 --- a/packages/common/test/Crypto.test.ts +++ b/packages/common/test/Crypto.test.ts @@ -12,13 +12,13 @@ import { import { mnemonicToOwnerSecret } from "../src/index.js"; import { ok } from "../src/Result.js"; import { testCreateDeps } from "../src/Test.js"; +import { testAppOwner } from "./local-first/_fixtures.js"; import { Mnemonic, type NonNegativeInt } from "../src/Type.js"; -import { testOwner } from "./local-first/_fixtures.js"; test("encryptWithXChaCha20Poly1305 / decryptWithXChaCha20Poly1305", () => { const deps = testCreateDeps(); const plaintext = utf8ToBytes("Hello, world!"); - const encryptionKey = testOwner.encryptionKey; + const encryptionKey = testAppOwner.encryptionKey; const [ciphertext, nonce] = encryptWithXChaCha20Poly1305(deps)( plaintext, diff --git a/packages/common/test/local-first/Owner.test.ts b/packages/common/test/local-first/Owner.test.ts index 73e73b127..1014653a8 100644 --- a/packages/common/test/local-first/Owner.test.ts +++ b/packages/common/test/local-first/Owner.test.ts @@ -9,10 +9,14 @@ import { ownerSecretToMnemonic, } from "../../src/index.js"; import { testCreateDeps } from "../../src/Test.js"; -import { testOwner, testOwnerSecret, testOwnerSecret2 } from "./_fixtures.js"; +import { + testAppOwner, + testAppOwnerSecret, + testAppOwner2Secret, +} from "./_fixtures.js"; test("ownerIdToOwnerIdBytes/ownerIdBytesToOwnerId", () => { - const id = testOwner.id; + const id = testAppOwner.id; expect(ownerIdBytesToOwnerId(ownerIdToOwnerIdBytes(id))).toStrictEqual(id); }); @@ -26,8 +30,8 @@ test("ownerSecretToMnemonic and mnemonicToOwnerSecret are inverses", () => { }); test("createAppOwner is deterministic", () => { - const owner1 = createAppOwner(testOwnerSecret); - const owner2 = createAppOwner(testOwnerSecret); + const owner1 = createAppOwner(testAppOwnerSecret); + const owner2 = createAppOwner(testAppOwnerSecret); expect(owner1).toEqual(owner2); expect(owner1.type).toBe("AppOwner"); @@ -35,7 +39,7 @@ test("createAppOwner is deterministic", () => { }); test("deriveShardOwner is deterministic", () => { - const appOwner = createAppOwner(testOwnerSecret); + const appOwner = createAppOwner(testAppOwnerSecret); const shard1 = deriveShardOwner(appOwner, ["contacts"]); const shard2 = deriveShardOwner(appOwner, ["contacts"]); @@ -45,7 +49,7 @@ test("deriveShardOwner is deterministic", () => { }); test("deriveShardOwner with different paths produces different owners", () => { - const appOwner = createAppOwner(testOwnerSecret); + const appOwner = createAppOwner(testAppOwnerSecret); const contacts = deriveShardOwner(appOwner, ["contacts"]); const photos = deriveShardOwner(appOwner, ["photos"]); @@ -56,7 +60,7 @@ test("deriveShardOwner with different paths produces different owners", () => { }); test("deriveShardOwner with nested paths", () => { - const appOwner = createAppOwner(testOwnerSecret); + const appOwner = createAppOwner(testAppOwnerSecret); const project1 = deriveShardOwner(appOwner, ["projects", "project-1"]); const project2 = deriveShardOwner(appOwner, ["projects", "project-2"]); @@ -67,8 +71,8 @@ test("deriveShardOwner with nested paths", () => { }); test("different app owners produce different shard owners", () => { - const appOwner1 = createAppOwner(testOwnerSecret); - const appOwner2 = createAppOwner(testOwnerSecret2); + const appOwner1 = createAppOwner(testAppOwnerSecret); + const appOwner2 = createAppOwner(testAppOwner2Secret); const shard1 = deriveShardOwner(appOwner1, ["contacts"]); const shard2 = deriveShardOwner(appOwner2, ["contacts"]); diff --git a/packages/common/test/local-first/Protocol.test.ts b/packages/common/test/local-first/Protocol.test.ts index fe07e8ce5..e3630871c 100644 --- a/packages/common/test/local-first/Protocol.test.ts +++ b/packages/common/test/local-first/Protocol.test.ts @@ -70,8 +70,8 @@ import { import { testCreateRelayStorageAndSqliteDeps } from "../_deps.js"; import { maxTimestamp, - testOwner, - testOwnerIdBytes, + testAppOwner, + testAppOwnerIdBytes, testTimestampsAsc, testTimestampsRandom, } from "./_fixtures.js"; @@ -436,7 +436,7 @@ const createEncryptedDbChange = ( deps: TestDeps, message: CrdtMessage, ): EncryptedDbChange => - encodeAndEncryptDbChange(deps)(message, testOwner.encryptionKey); + encodeAndEncryptDbChange(deps)(message, testAppOwner.encryptionKey); const createEncryptedCrdtMessage = ( deps: TestDeps, @@ -455,7 +455,7 @@ test("encodeAndEncryptDbChange/decryptAndDecodeDbChange", () => { ); const decrypted = decryptAndDecodeDbChange( encryptedMessage, - testOwner.encryptionKey, + testAppOwner.encryptionKey, ); assert(decrypted.ok); expect(decrypted.value).toEqual(crdtMessage.change); @@ -482,7 +482,7 @@ test("encodeAndEncryptDbChange/decryptAndDecodeDbChange", () => { }; const decryptedCorrupted = decryptAndDecodeDbChange( corruptedMessage, - testOwner.encryptionKey, + testAppOwner.encryptionKey, ); assert(!decryptedCorrupted.ok); expect(decryptedCorrupted.error.type).toBe( @@ -507,7 +507,7 @@ test("decryptAndDecodeDbChange timestamp tamper-proofing", () => { // Attempt to decrypt with wrong timestamp should fail with ProtocolTimestampMismatchError const decryptedWithWrongTimestamp = decryptAndDecodeDbChange( tamperedMessage, - testOwner.encryptionKey, + testAppOwner.encryptionKey, ); expect(decryptedWithWrongTimestamp).toEqual( @@ -599,7 +599,7 @@ describe("decodeRle", () => { describe("createProtocolMessageBuffer", () => { it("should allow no ranges", () => { - const buffer = createProtocolMessageBuffer(testOwner.id, { + const buffer = createProtocolMessageBuffer(testAppOwner.id, { messageType: MessageType.Request, }); expect(buffer.unwrap()).toMatchInlineSnapshot( @@ -608,7 +608,7 @@ describe("createProtocolMessageBuffer", () => { }); it("should allow single range with InfiniteUpperBound", () => { - const buffer = createProtocolMessageBuffer(testOwner.id, { + const buffer = createProtocolMessageBuffer(testAppOwner.id, { messageType: MessageType.Request, }); buffer.addRange({ @@ -619,7 +619,7 @@ describe("createProtocolMessageBuffer", () => { }); it("should reject single range without InfiniteUpperBound", () => { - const buffer = createProtocolMessageBuffer(testOwner.id, { + const buffer = createProtocolMessageBuffer(testAppOwner.id, { messageType: MessageType.Request, }); buffer.addRange({ @@ -632,7 +632,7 @@ describe("createProtocolMessageBuffer", () => { }); it("should allow multiple ranges with only last InfiniteUpperBound", () => { - const buffer = createProtocolMessageBuffer(testOwner.id, { + const buffer = createProtocolMessageBuffer(testAppOwner.id, { messageType: MessageType.Request, }); buffer.addRange({ @@ -651,7 +651,7 @@ describe("createProtocolMessageBuffer", () => { }); it("should reject range added after InfiniteUpperBound", () => { - const buffer = createProtocolMessageBuffer(testOwner.id, { + const buffer = createProtocolMessageBuffer(testAppOwner.id, { messageType: MessageType.Request, }); buffer.addRange({ @@ -667,7 +667,7 @@ describe("createProtocolMessageBuffer", () => { }); it("should reject multiple InfiniteUpperBounds", () => { - const buffer = createProtocolMessageBuffer(testOwner.id, { + const buffer = createProtocolMessageBuffer(testAppOwner.id, { messageType: MessageType.Request, }); buffer.addRange({ @@ -693,7 +693,7 @@ test("createProtocolMessageForSync", async () => { // Empty DB: version, ownerId, 0 messages, one empty TimestampsRange. expect( - createProtocolMessageForSync(storageDeps)(testOwner.id), + createProtocolMessageForSync(storageDeps)(testAppOwner.id), ).toMatchInlineSnapshot( `uint8:[1,251,208,27,154,71,19,37,213,195,24,203,60,255,39,7,11,0,0,0,0,1,2,0]`, ); @@ -708,11 +708,11 @@ test("createProtocolMessageForSync", async () => { }), ); assertNonEmptyArray(messages31); - await run(storageDeps.storage.writeMessages(testOwnerIdBytes, messages31)); + await run(storageDeps.storage.writeMessages(testAppOwnerIdBytes, messages31)); // DB with 31 timestamps: version, ownerId, 0 messages, one full (31) TimestampsRange. expect( - createProtocolMessageForSync(storageDeps)(testOwner.id), + createProtocolMessageForSync(storageDeps)(testAppOwner.id), ).toMatchInlineSnapshot( `uint8:[1,251,208,27,154,71,19,37,213,195,24,203,60,255,39,7,11,0,0,0,0,1,2,31,0,163,205,139,2,152,222,222,3,141,195,32,138,221,210,1,216,167,200,1,243,155,45,128,152,244,5,167,136,182,1,199,139,225,5,131,234,154,8,0,150,132,58,233,134,161,1,222,244,220,1,250,141,170,3,248,167,204,1,0,161,234,59,0,192,227,115,181,188,169,1,224,169,247,4,205,177,37,143,161,242,1,137,231,180,2,161,244,87,235,207,53,133,244,180,1,142,243,223,10,158,141,113,0,11,1,1,0,5,1,1,0,1,1,1,0,11,0,0,0,0,0,0,0,0,1,104,162,167,191,63,133,160,150,1,153,201,144,40,214,99,106,145,1,104,162,167,191,63,133,160,150,11,153,201,144,40,214,99,106,145,1,104,162,167,191,63,133,160,150,6,153,201,144,40,214,99,106,145,1,104,162,167,191,63,133,160,150,1,153,201,144,40,214,99,106,145,1,104,162,167,191,63,133,160,150,6,153,201,144,40,214,99,106,145,1]`, ); @@ -727,11 +727,11 @@ test("createProtocolMessageForSync", async () => { }), ); assertNonEmptyArray(message32); - await run(storageDeps.storage.writeMessages(testOwnerIdBytes, message32)); + await run(storageDeps.storage.writeMessages(testAppOwnerIdBytes, message32)); // DB with 32 timestamps: version, ownerId, 0 messages, 16x FingerprintRange. expect( - createProtocolMessageForSync(storageDeps)(testOwner.id), + createProtocolMessageForSync(storageDeps)(testAppOwner.id), ).toMatchInlineSnapshot( `uint8:[1,251,208,27,154,71,19,37,213,195,24,203,60,255,39,7,11,0,0,0,0,16,187,171,234,5,151,160,243,1,203,195,245,1,167,160,170,7,202,245,251,13,150,132,58,199,251,253,2,242,181,246,4,161,234,59,192,227,115,149,230,160,6,220,210,151,2,170,219,140,3,240,195,234,1,172,128,209,11,0,15,153,201,144,40,214,99,106,145,1,104,162,167,191,63,133,160,150,5,153,201,144,40,214,99,106,145,1,104,162,167,191,63,133,160,150,7,153,201,144,40,214,99,106,145,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,79,199,221,49,166,129,34,35,99,27,109,221,72,203,113,173,13,174,108,244,220,53,10,79,91,208,39,170,201,18,73,253,152,51,99,124,0,152,50,246,239,212,6,13,80,19,126,71,76,18,73,200,62,200,42,99,188,63,73,207,154,238,98,14,224,33,103,255,188,202,60,84,33,248,184,78,240,231,221,198,98,244,79,237,208,100,110,251,209,4,221,129,70,179,162,173,26,9,38,199,115,85,231,208,141,13,135,35,144,151,124,233,151,6,119,79,51,128,236,157,32,91,160,104,143,239,236,16,148,246,215,168,225,200,73,253,182,117,53,113,24,52,165,196,73,55,66,212,228,27,187,1,71,143,234,75,93,129,254,145,224,183,203,200,8,205,21,142,6,139,145,237,12,30,146,233,222,152,203,251,132,199,125,55,190,43,113,63,180,29,179,161]`, ); @@ -742,7 +742,7 @@ describe("E2E versioning", () => { await using run = testCreateRunner(shouldNotBeCalledStorageDep); const v0 = 0 as NonNegativeInt; - const clientMessage = createProtocolMessageBuffer(testOwner.id, { + const clientMessage = createProtocolMessageBuffer(testAppOwner.id, { version: v0, messageType: MessageType.Request, }).unwrap(); @@ -760,7 +760,7 @@ describe("E2E versioning", () => { const v0 = 0 as NonNegativeInt; const v1 = 1 as NonNegativeInt; - const clientMessage = createProtocolMessageBuffer(testOwner.id, { + const clientMessage = createProtocolMessageBuffer(testAppOwner.id, { version: v0, messageType: MessageType.Request, }).unwrap(); @@ -780,7 +780,7 @@ describe("E2E versioning", () => { type: "ProtocolVersionError", version: 1, isInitiator: true, - ownerId: testOwner.id, + ownerId: testAppOwner.id, }), ); }); @@ -790,7 +790,7 @@ describe("E2E versioning", () => { const v0 = 0 as NonNegativeInt; const v1 = 1 as NonNegativeInt; - const clientMessage = createProtocolMessageBuffer(testOwner.id, { + const clientMessage = createProtocolMessageBuffer(testAppOwner.id, { version: v1, messageType: MessageType.Request, }).unwrap(); @@ -810,7 +810,7 @@ describe("E2E versioning", () => { type: "ProtocolVersionError", version: 0, isInitiator: false, - ownerId: testOwner.id, + ownerId: testAppOwner.id, }), ); }); @@ -842,7 +842,7 @@ describe("E2E errors", () => { ]; const initiatorMessage = createProtocolMessageFromCrdtMessages(deps)( - testOwner, + testAppOwner, messages, ); @@ -867,7 +867,7 @@ describe("E2E errors", () => { applyProtocolMessageAsClient(responseMessage), ); expect(clientResult).toEqual( - err({ type: "ProtocolWriteKeyError", ownerId: testOwner.id }), + err({ type: "ProtocolWriteKeyError", ownerId: testAppOwner.id }), ); }); }); @@ -875,7 +875,7 @@ describe("E2E errors", () => { describe("E2E relay options", () => { test("subscribe", async () => { await using run = testCreateRunner(shouldNotBeCalledStorageDep); - const message = createProtocolMessageBuffer(testOwner.id, { + const message = createProtocolMessageBuffer(testAppOwner.id, { messageType: MessageType.Request, subscriptionFlag: SubscriptionFlags.Subscribe, }).unwrap(); @@ -890,12 +890,12 @@ describe("E2E relay options", () => { }), ); - expect(subscribeCalledWithOwnerId).toBe(testOwner.id); + expect(subscribeCalledWithOwnerId).toBe(testAppOwner.id); }); test("unsubscribe", async () => { await using run = testCreateRunner(shouldNotBeCalledStorageDep); - const message = createProtocolMessageBuffer(testOwner.id, { + const message = createProtocolMessageBuffer(testAppOwner.id, { messageType: MessageType.Request, subscriptionFlag: SubscriptionFlags.Unsubscribe, }).unwrap(); @@ -909,12 +909,12 @@ describe("E2E relay options", () => { }), ); - expect(unsubscribeCalledWithOwnerId).toBe(testOwner.id); + expect(unsubscribeCalledWithOwnerId).toBe(testAppOwner.id); }); test("no subscription flag (None)", async () => { await using run = testCreateRunner(shouldNotBeCalledStorageDep); - const message = createProtocolMessageBuffer(testOwner.id, { + const message = createProtocolMessageBuffer(testAppOwner.id, { messageType: MessageType.Request, subscriptionFlag: SubscriptionFlags.None, }).unwrap(); @@ -939,7 +939,7 @@ describe("E2E relay options", () => { test("default subscription flag (undefined)", async () => { await using run = testCreateRunner(shouldNotBeCalledStorageDep); - const message = createProtocolMessageBuffer(testOwner.id, { + const message = createProtocolMessageBuffer(testAppOwner.id, { messageType: MessageType.Request, // No subscriptionFlag provided, should default to None }).unwrap(); @@ -971,7 +971,7 @@ describe("E2E relay options", () => { ]; const initiatorMessage = createProtocolMessageFromCrdtMessages(deps)( - testOwner, + testAppOwner, messages, ); @@ -991,7 +991,7 @@ describe("E2E relay options", () => { await run( applyProtocolMessageAsRelay(initiatorMessage, { broadcast: (ownerId, message) => { - expect(ownerId).toBe(testOwner.id); + expect(ownerId).toBe(testAppOwner.id); broadcastedMessage = message; }, }), @@ -1065,7 +1065,9 @@ describe("E2E sync", () => { const clientStorageDep = { storage: clientStorage }; const relayStorageDep = { storage: relayStorage }; - let message = createProtocolMessageForSync(clientStorageDep)(testOwner.id); + let message = createProtocolMessageForSync(clientStorageDep)( + testAppOwner.id, + ); assert(message); let result; @@ -1090,7 +1092,7 @@ describe("E2E sync", () => { await using run = testCreateRunner(clientStorageDep); result = await run( applyProtocolMessageAsClient(message, { - getWriteKey: () => testOwner.writeKey, + getWriteKey: () => testAppOwner.writeKey, rangesMaxSize, }), ); @@ -1108,7 +1110,7 @@ describe("E2E sync", () => { expect( clientStorage .readDbChange( - testOwnerIdBytes, + testAppOwnerIdBytes, timestampToTimestampBytes(message.timestamp), ) ?.join(), @@ -1117,7 +1119,7 @@ describe("E2E sync", () => { expect( relayStorage .readDbChange( - testOwnerIdBytes, + testAppOwnerIdBytes, timestampToTimestampBytes(message.timestamp), ) ?.join(), @@ -1133,8 +1135,8 @@ describe("E2E sync", () => { it("client and relay have all data", { timeout: 15000 }, async () => { await using run = testCreateRunner(); const [clientStorage, relayStorage] = await createStorages(); - await run(clientStorage.writeMessages(testOwnerIdBytes, messages)); - await run(relayStorage.writeMessages(testOwnerIdBytes, messages)); + await run(clientStorage.writeMessages(testAppOwnerIdBytes, messages)); + await run(relayStorage.writeMessages(testAppOwnerIdBytes, messages)); const syncSteps = await reconcile(clientStorage, relayStorage); expect(syncSteps).toMatchInlineSnapshot(` @@ -1151,7 +1153,7 @@ describe("E2E sync", () => { it("client has all data", async () => { await using run = testCreateRunner(); const [clientStorage, relayStorage] = await createStorages(); - await run(clientStorage.writeMessages(testOwnerIdBytes, messages)); + await run(clientStorage.writeMessages(testAppOwnerIdBytes, messages)); const syncSteps = await reconcile(clientStorage, relayStorage); expect(syncSteps).toMatchInlineSnapshot(` @@ -1172,7 +1174,7 @@ describe("E2E sync", () => { it("client has all data - many steps", { timeout: 15000 }, async () => { await using run = testCreateRunner(); const [clientStorage, relayStorage] = await createStorages(); - await run(clientStorage.writeMessages(testOwnerIdBytes, messages)); + await run(clientStorage.writeMessages(testAppOwnerIdBytes, messages)); const syncSteps = await reconcile( clientStorage, @@ -1205,7 +1207,7 @@ describe("E2E sync", () => { it("relay has all data", async () => { await using run = testCreateRunner(); const [clientStorage, relayStorage] = await createStorages(); - await run(relayStorage.writeMessages(testOwnerIdBytes, messages)); + await run(relayStorage.writeMessages(testAppOwnerIdBytes, messages)); const syncSteps = await reconcile(clientStorage, relayStorage); expect(syncSteps).toMatchInlineSnapshot(` @@ -1224,7 +1226,7 @@ describe("E2E sync", () => { it("relay has all data - many steps", { timeout: 15000 }, async () => { await using run = testCreateRunner(); const [clientStorage, relayStorage] = await createStorages(); - await run(relayStorage.writeMessages(testOwnerIdBytes, messages)); + await run(relayStorage.writeMessages(testAppOwnerIdBytes, messages)); const syncSteps = await reconcile( clientStorage, @@ -1279,8 +1281,8 @@ describe("E2E sync", () => { assertNonEmptyArray(firstHalf); assertNonEmptyArray(secondHalf); - await run(clientStorage.writeMessages(testOwnerIdBytes, firstHalf)); - await run(relayStorage.writeMessages(testOwnerIdBytes, secondHalf)); + await run(clientStorage.writeMessages(testAppOwnerIdBytes, firstHalf)); + await run(relayStorage.writeMessages(testAppOwnerIdBytes, secondHalf)); const syncSteps = await reconcile(clientStorage, relayStorage); expect(syncSteps).toMatchInlineSnapshot(` @@ -1314,8 +1316,8 @@ describe("E2E sync", () => { assertNonEmptyArray(firstHalf); assertNonEmptyArray(secondHalf); - await run(clientStorage.writeMessages(testOwnerIdBytes, firstHalf)); - await run(relayStorage.writeMessages(testOwnerIdBytes, secondHalf)); + await run(clientStorage.writeMessages(testAppOwnerIdBytes, firstHalf)); + await run(relayStorage.writeMessages(testAppOwnerIdBytes, secondHalf)); const syncSteps = await reconcile( clientStorage, @@ -1371,7 +1373,7 @@ describe("E2E sync", () => { ); it("starts sync from createProtocolMessageFromCrdtMessages", async () => { - const owner = testOwner; + const owner = testAppOwner; const crdtMessages = testTimestampsAsc.map( (t): CrdtMessage => ({ timestamp: timestampBytesToTimestamp(t), @@ -1409,7 +1411,7 @@ describe("E2E sync", () => { describe("ranges sizes", () => { it("31 timestamps", () => { - const buffer = createProtocolMessageBuffer(testOwner.id, { + const buffer = createProtocolMessageBuffer(testAppOwner.id, { messageType: MessageType.Request, }); const range: TimestampsRangeWithTimestampsBuffer = { @@ -1429,7 +1431,7 @@ describe("ranges sizes", () => { }); it("testTimestampsAsc", () => { - const buffer = createProtocolMessageBuffer(testOwner.id, { + const buffer = createProtocolMessageBuffer(testAppOwner.id, { messageType: MessageType.Request, }); @@ -1450,7 +1452,7 @@ describe("ranges sizes", () => { }); it("fingerprints", () => { - const buffer = createProtocolMessageBuffer(testOwner.id, { + const buffer = createProtocolMessageBuffer(testAppOwner.id, { messageType: MessageType.Request, }); diff --git a/packages/common/test/local-first/Relay.test.ts b/packages/common/test/local-first/Relay.test.ts index 55c66daf6..221b9adba 100644 --- a/packages/common/test/local-first/Relay.test.ts +++ b/packages/common/test/local-first/Relay.test.ts @@ -19,10 +19,10 @@ import { createInitialTimestamp } from "../../src/local-first/Timestamp.js"; import { testCreateDeps } from "../../src/Test.js"; import { testCreateRunnerWithRelayStorage } from "../_deps.js"; import { - testOwner, - testOwner2, - testOwnerIdBytes, - testOwnerIdBytes2, + testAppOwner, + testAppOwner2, + testAppOwnerIdBytes, + testAppOwner2IdBytes, testTimestampsAsc, } from "./_fixtures.js"; @@ -30,19 +30,22 @@ test("validateWriteKey", async () => { await using run = await testCreateRunnerWithRelayStorage(); const { storage } = run.deps; - const writeKey = testOwner.writeKey; - const differentWriteKey = testOwner2.writeKey; + const writeKey = testAppOwner.writeKey; + const differentWriteKey = testAppOwner2.writeKey; // New owner - const result1 = storage.validateWriteKey(testOwnerIdBytes, writeKey); + const result1 = storage.validateWriteKey(testAppOwnerIdBytes, writeKey); expect(result1).toBe(true); // Existing owner, same write key - const result2 = storage.validateWriteKey(testOwnerIdBytes, writeKey); + const result2 = storage.validateWriteKey(testAppOwnerIdBytes, writeKey); expect(result2).toBe(true); // Existing owner ID, different write key - const result3 = storage.validateWriteKey(testOwnerIdBytes, differentWriteKey); + const result3 = storage.validateWriteKey( + testAppOwnerIdBytes, + differentWriteKey, + ); expect(result3).toBe(false); }); @@ -50,25 +53,25 @@ test("deleteOwner", async () => { await using run = await testCreateRunnerWithRelayStorage(); const { storage, sqlite } = run.deps; - storage.setWriteKey(testOwnerIdBytes, testOwner.writeKey); + storage.setWriteKey(testAppOwnerIdBytes, testAppOwner.writeKey); const message: EncryptedCrdtMessage = { timestamp: timestampBytesToTimestamp(testTimestampsAsc[0]), change: new Uint8Array([1, 2, 3]) as EncryptedDbChange, }; - await run(storage.writeMessages(testOwnerIdBytes, [message])); + await run(storage.writeMessages(testAppOwnerIdBytes, [message])); - expect(storage.getSize(testOwnerIdBytes)).toBe(1); + expect(storage.getSize(testAppOwnerIdBytes)).toBe(1); - const deleteResult = storage.deleteOwner(testOwnerIdBytes); + const deleteResult = storage.deleteOwner(testAppOwnerIdBytes); expect(deleteResult).toBe(true); for (const table of ["evolu_timestamp", "evolu_message", "evolu_writeKey"]) { const countResult = sqlite.exec<{ count: number }>(sql` select count(*) as count from ${sql.raw(table)} - where ownerid = ${testOwnerIdBytes}; + where ownerid = ${testAppOwnerIdBytes}; `); expect(countResult.ok && countResult.value.rows[0].count).toBe(0); } @@ -99,19 +102,19 @@ describe("writeMessages", () => { await using run = await testCreateRunnerWithRelayStorage(); const { storage, sqlite } = run.deps; - await run(storage.writeMessages(testOwnerIdBytes, [message])); + await run(storage.writeMessages(testAppOwnerIdBytes, [message])); - expect(getStoredBytes({ sqlite })(testOwnerIdBytes)).toBe(3); + expect(getStoredBytes({ sqlite })(testAppOwnerIdBytes)).toBe(3); }); test("accumulates storedBytes across multiple writes", async () => { await using run = await testCreateRunnerWithRelayStorage(); const { storage, sqlite } = run.deps; - await run(storage.writeMessages(testOwnerIdBytes, [message])); - await run(storage.writeMessages(testOwnerIdBytes, [message])); + await run(storage.writeMessages(testAppOwnerIdBytes, [message])); + await run(storage.writeMessages(testAppOwnerIdBytes, [message])); - expect(getStoredBytes({ sqlite })(testOwnerIdBytes)).toBe(6); + expect(getStoredBytes({ sqlite })(testAppOwnerIdBytes)).toBe(6); }); test("prevents duplicate timestamp writes", async () => { @@ -119,19 +122,19 @@ describe("writeMessages", () => { const { storage, sqlite } = run.deps; const result1 = await run( - storage.writeMessages(testOwnerIdBytes, [message]), + storage.writeMessages(testAppOwnerIdBytes, [message]), ); assert(result1.ok); const result2 = await run( - storage.writeMessages(testOwnerIdBytes, [message]), + storage.writeMessages(testAppOwnerIdBytes, [message]), ); assert(result2.ok); const countResult = sqlite.exec<{ count: number }>(sql` select count(*) as count from evolu_message - where ownerid = ${testOwnerIdBytes}; + where ownerid = ${testAppOwnerIdBytes}; `); assert(countResult.ok); @@ -159,12 +162,12 @@ describe("writeMessages", () => { const message2 = createTestMessage(); await Promise.all([ - run(storage.writeMessages(testOwnerIdBytes, [message1])), - run(storage.writeMessages(testOwnerIdBytes, [message2])), + run(storage.writeMessages(testAppOwnerIdBytes, [message1])), + run(storage.writeMessages(testAppOwnerIdBytes, [message2])), ]); expect(concurrentAccess).toBe(false); - expect(storage.getSize(testOwnerIdBytes)).toBe(2); + expect(storage.getSize(testAppOwnerIdBytes)).toBe(2); }); test("allows concurrent writes for different owners", async () => { @@ -186,13 +189,13 @@ describe("writeMessages", () => { const message2 = createTestMessage(); await Promise.all([ - run(storage.writeMessages(testOwnerIdBytes, [message1])), - run(storage.writeMessages(testOwnerIdBytes2, [message2])), + run(storage.writeMessages(testAppOwnerIdBytes, [message1])), + run(storage.writeMessages(testAppOwner2IdBytes, [message2])), ]); expect(maxConcurrentWrites).toBe(2); - expect(storage.getSize(testOwnerIdBytes)).toBe(1); - expect(storage.getSize(testOwnerIdBytes2)).toBe(1); + expect(storage.getSize(testAppOwnerIdBytes)).toBe(1); + expect(storage.getSize(testAppOwner2IdBytes)).toBe(1); }); test("transaction rollback on quota error", async () => { @@ -202,17 +205,17 @@ describe("writeMessages", () => { const { storage, sqlite } = run.deps; const result = await run( - storage.writeMessages(testOwnerIdBytes, [message]), + storage.writeMessages(testAppOwnerIdBytes, [message]), ); expect(result).toEqual( - err({ type: "StorageQuotaError", ownerId: testOwner.id }), + err({ type: "StorageQuotaError", ownerId: testAppOwner.id }), ); const messageCountResult = sqlite.exec<{ count: number }>(sql` select count(*) as count from evolu_message - where ownerid = ${testOwnerIdBytes}; + where ownerid = ${testAppOwnerIdBytes}; `); assert(messageCountResult.ok); @@ -221,7 +224,7 @@ describe("writeMessages", () => { const usageResult = sqlite.exec<{ count: number }>(sql` select count(*) as count from evolu_usage - where ownerid = ${testOwnerIdBytes}; + where ownerid = ${testAppOwnerIdBytes}; `); assert(usageResult.ok); @@ -245,12 +248,12 @@ describe("writeMessages", () => { const { storage } = run.deps; const result = await run( - storage.writeMessages(testOwnerIdBytes, [message]), + storage.writeMessages(testAppOwnerIdBytes, [message]), ); assert(result.ok); expect(quotaCheckCalled).toBe(true); - expect(receivedOwnerId).toBe(testOwner.id); + expect(receivedOwnerId).toBe(testAppOwner.id); expect(receivedBytes).toBe(3); }); @@ -271,12 +274,12 @@ describe("writeMessages", () => { const { storage } = run.deps; const result = await run( - storage.writeMessages(testOwnerIdBytes, [message]), + storage.writeMessages(testAppOwnerIdBytes, [message]), ); assert(result.ok); expect(quotaCheckCalled).toBe(true); - expect(receivedOwnerId).toBe(testOwner.id); + expect(receivedOwnerId).toBe(testAppOwner.id); expect(receivedBytes).toBe(3); }); @@ -287,11 +290,11 @@ describe("writeMessages", () => { const { storage } = run.deps; const result = await run( - storage.writeMessages(testOwnerIdBytes, [message]), + storage.writeMessages(testAppOwnerIdBytes, [message]), ); expect(result).toEqual( - err({ type: "StorageQuotaError", ownerId: testOwner.id }), + err({ type: "StorageQuotaError", ownerId: testAppOwner.id }), ); }); @@ -305,11 +308,11 @@ describe("writeMessages", () => { const { storage } = run.deps; const result = await run( - storage.writeMessages(testOwnerIdBytes, [message]), + storage.writeMessages(testAppOwnerIdBytes, [message]), ); expect(result).toEqual( - err({ type: "StorageQuotaError", ownerId: testOwner.id }), + err({ type: "StorageQuotaError", ownerId: testAppOwner.id }), ); }); @@ -324,25 +327,25 @@ describe("writeMessages", () => { const message1 = createTestMessage(50); const result1 = await run( - storage.writeMessages(testOwnerIdBytes, [message1]), + storage.writeMessages(testAppOwnerIdBytes, [message1]), ); assert(result1.ok); const message2 = createTestMessage(40); const result2 = await run( - storage.writeMessages(testOwnerIdBytes, [message2]), + storage.writeMessages(testAppOwnerIdBytes, [message2]), ); assert(result2.ok); const largeMessage = createTestMessage(20); const result3 = await run( - storage.writeMessages(testOwnerIdBytes, [largeMessage]), + storage.writeMessages(testAppOwnerIdBytes, [largeMessage]), ); expect(result3).toEqual( - err({ type: "StorageQuotaError", ownerId: testOwner.id }), + err({ type: "StorageQuotaError", ownerId: testAppOwner.id }), ); - expect(getStoredBytes({ sqlite })(testOwnerIdBytes)).toBe(90); + expect(getStoredBytes({ sqlite })(testAppOwnerIdBytes)).toBe(90); }); }); }); diff --git a/packages/common/test/local-first/Storage.test.ts b/packages/common/test/local-first/Storage.test.ts index 3ae307dae..211f0a6fb 100644 --- a/packages/common/test/local-first/Storage.test.ts +++ b/packages/common/test/local-first/Storage.test.ts @@ -34,8 +34,8 @@ import { createId, NonNegativeInt, type PositiveInt } from "../../src/Type.js"; import { testCreateSqlite } from "../_deps.js"; import { testAnotherTimestampsAsc, - testOwner2, - testOwnerIdBytes, + testAppOwner2, + testAppOwnerIdBytes, testTimestampsAsc, testTimestampsDesc, testTimestampsRandom, @@ -82,18 +82,18 @@ const testTimestamps = async ( const txResult = deps.sqlite.transaction(() => { for (const timestamp of timestamps) { - deps.storage.insertTimestamp(testOwnerIdBytes, timestamp, strategy); + deps.storage.insertTimestamp(testAppOwnerIdBytes, timestamp, strategy); } // Add the same timestamps again to test idempotency. for (const timestamp of timestamps) { - deps.storage.insertTimestamp(testOwnerIdBytes, timestamp, strategy); + deps.storage.insertTimestamp(testAppOwnerIdBytes, timestamp, strategy); } // Add similar timestamps of another owner. for (const timestamp of testAnotherTimestampsAsc) { deps.storage.insertTimestamp( - ownerIdToOwnerIdBytes(testOwner2.id), + ownerIdToOwnerIdBytes(testAppOwner2.id), timestamp, "append", ); @@ -102,7 +102,7 @@ const testTimestamps = async ( }); assert(txResult.ok); - const count = deps.storage.getSize(testOwnerIdBytes); + const count = deps.storage.getSize(testAppOwnerIdBytes); assert(count); expect(count).toBe(timestamps.length); @@ -110,7 +110,7 @@ const testTimestamps = async ( assert(buckets.ok, JSON.stringify(buckets)); const fingerprintRanges = deps.storage.fingerprintRanges( - testOwnerIdBytes, + testAppOwnerIdBytes, buckets.value, ); assert(fingerprintRanges); @@ -141,7 +141,7 @@ const testTimestamps = async ( where (${lower} is null or t >= ${lower}) and (${upper} is null or t < ${upper}) - and ownerid = ${testOwnerIdBytes}; + and ownerid = ${testAppOwnerIdBytes}; `), ); @@ -156,7 +156,7 @@ const testTimestamps = async ( ); const fingerprintResult = deps.storage.fingerprint( - testOwnerIdBytes, + testAppOwnerIdBytes, NonNegativeInt.orThrow(i > 0 ? buckets.value[i - 1] : 0), NonNegativeInt.orThrow(buckets.value[i]), ); @@ -171,7 +171,7 @@ const testTimestamps = async ( // The whole DB fingerprint. const oneRangeFingerprints = deps.storage.fingerprintRanges( - testOwnerIdBytes, + testAppOwnerIdBytes, [timestamps.length as PositiveInt], ); assert(oneRangeFingerprints); @@ -207,18 +207,18 @@ test( test("empty db", async () => { const deps = await createDeps(); - const size = deps.storage.getSize(testOwnerIdBytes); + const size = deps.storage.getSize(testAppOwnerIdBytes); expect(size).toBe(0); const fingerprint = deps.storage.fingerprint( - testOwnerIdBytes, + testAppOwnerIdBytes, 0 as NonNegativeInt, 0 as NonNegativeInt, ); expect(fingerprint?.join()).toBe("0,0,0,0,0,0,0,0,0,0,0,0"); const lowerBound = deps.storage.findLowerBound( - testOwnerIdBytes, + testAppOwnerIdBytes, 0 as NonNegativeInt, 0 as NonNegativeInt, testTimestampsAsc[0], @@ -246,7 +246,11 @@ const benchmarkTimestamps = async ( const batchBeginTime = performance.now(); deps.sqlite.transaction(() => { for (let i = batchStart; i < batchEnd; i++) { - deps.storage.insertTimestamp(testOwnerIdBytes, timestamps[i], strategy); + deps.storage.insertTimestamp( + testAppOwnerIdBytes, + timestamps[i], + strategy, + ); } return ok(); }); @@ -254,12 +258,12 @@ const benchmarkTimestamps = async ( const insertsPerSec = ((batchEnd - batchStart) / batchTimeSec).toFixed(0); const bucketsBeginTime = performance.now(); - const size = deps.storage.getSize(testOwnerIdBytes); + const size = deps.storage.getSize(testAppOwnerIdBytes); assert(size); const buckets = computeBalancedBuckets(size); assert(buckets.ok); const fingerprint = deps.storage.fingerprintRanges( - testOwnerIdBytes, + testAppOwnerIdBytes, buckets.value, ); assert(fingerprint); @@ -287,10 +291,10 @@ test("findLowerBound", async () => { timestampToTimestampBytes(createTimestamp({ millis: (i + 1) as Millis })), ); for (const t of timestamps) { - storage.insertTimestamp(testOwnerIdBytes, t, "append"); + storage.insertTimestamp(testAppOwnerIdBytes, t, "append"); } - const ownerId = testOwnerIdBytes; + const ownerId = testAppOwnerIdBytes; const begin = NonNegativeInt.orThrow(0); const end = NonNegativeInt.orThrow(10); @@ -327,12 +331,12 @@ test("iterate", async () => { const deps = await createDeps(); for (const timestamp of testTimestampsAsc) { - deps.storage.insertTimestamp(testOwnerIdBytes, timestamp, "append"); + deps.storage.insertTimestamp(testAppOwnerIdBytes, timestamp, "append"); } const collected: Array = []; deps.storage.iterate( - testOwnerIdBytes, + testAppOwnerIdBytes, 0 as NonNegativeInt, testTimestampsAsc.length as NonNegativeInt, (timestamp, index) => { @@ -350,7 +354,7 @@ test("iterate", async () => { const stopAfter = 3; const stopAfterCollected: Array = []; deps.storage.iterate( - testOwnerIdBytes, + testAppOwnerIdBytes, 0 as NonNegativeInt, testTimestampsAsc.length as NonNegativeInt, (timestamp) => { @@ -370,12 +374,12 @@ test("getTimestampByIndex", async () => { const deps = await createDeps(); for (const timestamp of testTimestampsAsc) { - deps.storage.insertTimestamp(testOwnerIdBytes, timestamp, "append"); + deps.storage.insertTimestamp(testAppOwnerIdBytes, timestamp, "append"); } for (let i = 0; i < testTimestampsAsc.length; i++) { const timestamp = getTimestampByIndex(deps)( - testOwnerIdBytes, + testAppOwnerIdBytes, i as NonNegativeInt, ); assert(timestamp.ok); diff --git a/packages/common/test/local-first/_fixtures.ts b/packages/common/test/local-first/_fixtures.ts index 309bac7c8..1ffc49d41 100644 --- a/packages/common/test/local-first/_fixtures.ts +++ b/packages/common/test/local-first/_fixtures.ts @@ -78,15 +78,14 @@ export const testAnotherTimestampsAsc = timestamps .toSorted(orderTimestampBytes) .slice(0, 1000); -export const testOwnerSecret = createOwnerSecret({ +export const testAppOwnerSecret = createOwnerSecret({ randomBytes: deps.randomBytes, }); -export const testOwnerSecret2 = createOwnerSecret({ +export const testAppOwner = createAppOwner(testAppOwnerSecret); +export const testAppOwnerIdBytes = ownerIdToOwnerIdBytes(testAppOwner.id); + +export const testAppOwner2Secret = createOwnerSecret({ randomBytes: deps.randomBytes, }); - -export const testOwner = createAppOwner(testOwnerSecret); -export const testOwnerIdBytes = ownerIdToOwnerIdBytes(testOwner.id); - -export const testOwner2 = createAppOwner(testOwnerSecret2); -export const testOwnerIdBytes2 = ownerIdToOwnerIdBytes(testOwner2.id); +export const testAppOwner2 = createAppOwner(testAppOwner2Secret); +export const testAppOwner2IdBytes = ownerIdToOwnerIdBytes(testAppOwner2.id); From 2722409e4a4eea24aff3c489045037903a4b7ae7 Mon Sep 17 00:00:00 2001 From: Miccy Date: Sat, 7 Feb 2026 20:30:23 +0100 Subject: [PATCH 03/37] chore(sync): apply Biome import ordering after cherry-pick 82c941ad --- packages/common/test/Crypto.test.ts | 2 +- packages/common/test/local-first/Owner.test.ts | 2 +- packages/common/test/local-first/Relay.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/common/test/Crypto.test.ts b/packages/common/test/Crypto.test.ts index 8e67380af..023581fc9 100644 --- a/packages/common/test/Crypto.test.ts +++ b/packages/common/test/Crypto.test.ts @@ -12,8 +12,8 @@ import { import { mnemonicToOwnerSecret } from "../src/index.js"; import { ok } from "../src/Result.js"; import { testCreateDeps } from "../src/Test.js"; -import { testAppOwner } from "./local-first/_fixtures.js"; import { Mnemonic, type NonNegativeInt } from "../src/Type.js"; +import { testAppOwner } from "./local-first/_fixtures.js"; test("encryptWithXChaCha20Poly1305 / decryptWithXChaCha20Poly1305", () => { const deps = testCreateDeps(); diff --git a/packages/common/test/local-first/Owner.test.ts b/packages/common/test/local-first/Owner.test.ts index 1014653a8..174c61bf8 100644 --- a/packages/common/test/local-first/Owner.test.ts +++ b/packages/common/test/local-first/Owner.test.ts @@ -11,8 +11,8 @@ import { import { testCreateDeps } from "../../src/Test.js"; import { testAppOwner, - testAppOwnerSecret, testAppOwner2Secret, + testAppOwnerSecret, } from "./_fixtures.js"; test("ownerIdToOwnerIdBytes/ownerIdBytesToOwnerId", () => { diff --git a/packages/common/test/local-first/Relay.test.ts b/packages/common/test/local-first/Relay.test.ts index 221b9adba..86b247891 100644 --- a/packages/common/test/local-first/Relay.test.ts +++ b/packages/common/test/local-first/Relay.test.ts @@ -21,8 +21,8 @@ import { testCreateRunnerWithRelayStorage } from "../_deps.js"; import { testAppOwner, testAppOwner2, - testAppOwnerIdBytes, testAppOwner2IdBytes, + testAppOwnerIdBytes, testTimestampsAsc, } from "./_fixtures.js"; From 2c116d9b94ae049a38aecf50a5a05ec47ce6a8c7 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Thu, 5 Feb 2026 18:21:38 +0100 Subject: [PATCH 04/37] Add incremental TS builds and workspace references - Enabled composite mode in base tsconfig with incremental builds - Added references between packages matching their dependency graph - Changed build scripts from tsc to tsc --build - Removed dev scripts from library packages (no longer needed) - Simplified pnpm dev to only start relay and web servers - Added pnpm relay for mobile development workflow - Updated turbo.json schema URL and removed ^dev dependency - Added .tsBuildInfo to gitignore After pnpm build, the IDE has full cross-package types without needing a background watcher. This reduces CPU/memory usage and eliminates delays waiting for Turbo to rebuild on changes. (cherry picked from commit f79f9f8626834efa0998f72fea574bf82a163131) --- .github/copilot-instructions.md | 11 ++++++----- .gitignore | 1 + .vscode/settings.json | 8 +++++++- apps/relay/package.json | 2 +- examples/react-expo/package.json | 2 +- package.json | 3 ++- packages/common/package.json | 3 +-- packages/common/tsconfig.json | 3 ++- packages/nodejs/package.json | 3 +-- packages/nodejs/tsconfig.json | 4 +++- packages/react-native/package.json | 3 +-- packages/react-native/tsconfig.json | 7 +++++-- packages/react-web/package.json | 4 ++-- packages/react-web/src/index.ts | 11 +---------- packages/react-web/tsconfig.json | 10 +++++++--- packages/react/package.json | 3 +-- packages/react/tsconfig.json | 9 ++++++--- packages/svelte/package.json | 4 ++-- packages/svelte/tsconfig.json | 11 +++++++++-- packages/tsconfig.json | 13 +++++++++++++ packages/tsconfig/base.json | 3 ++- packages/vue/package.json | 3 +-- packages/vue/tsconfig.json | 9 ++++++--- packages/web/package.json | 4 ++-- packages/web/tsconfig.json | 8 ++++++-- turbo.json | 3 +-- 26 files changed, 90 insertions(+), 55 deletions(-) create mode 100644 packages/tsconfig.json diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 528e3e356..7975034d0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -73,8 +73,8 @@ apps/ ```bash pnpm install # Install dependencies (Node >=24.0.0) -pnpm build # Build all packages -pnpm dev # Watch mode for development +pnpm build # Build all packages (required once for IDE types) +pnpm dev # Start relay and web servers pnpm test # Run all tests pnpm test:coverage # With coverage pnpm lint # ESLint @@ -497,6 +497,7 @@ const deps: TimeDep & Partial = { - **Single deps argument** - functions accept one `deps` parameter combining dependencies - **Wrap dependencies** - use `TimeDep`, `LoggerDep` etc. to avoid property clashes +- **Skip JSDoc for simple dep interfaces** - `interface TimeDep { readonly time: Time }` is self-documenting - **Over-providing is OK** - passing extra deps is fine, over-depending is not - **Use Partial<>** for optional dependencies - **No global static instances** - avoid service locator pattern @@ -532,7 +533,7 @@ const result = await sleep("1s")(run); ## Test-driven development -- Write a failing test before implementing a new feature or fixing a bug +- Write a test before implementing a new feature or fixing a bug - Run tests using the `runTests` tool with the test file path - Test files are in `packages/*/test/*.test.ts` - Use `testNames` parameter to run specific tests by name @@ -594,7 +595,7 @@ test("Buffer", () => { ## Testing -- **Create deps per test** - use `testCreateDeps()` from `@evolu/common` for test isolation +- **Use Test module** - `packages/common/src/Test.ts` provides `testCreateDeps()` and `testCreateRun()` for test isolation - **Naming convention** - test factories follow `testCreateX` pattern (e.g., `testCreateTime`, `testCreateRandom`) - Mock dependencies using the same interfaces - Never rely on global state or shared mutable deps between tests @@ -604,7 +605,7 @@ test("Buffer", () => { Create fresh deps at the start of each test for isolation. Each call creates independent instances, preventing shared state between tests. ```ts -import { testCreateDeps, createId } from "@evolu/common"; +import { testCreateDeps, testCreateRun } from "@evolu/common"; test("creates unique IDs", () => { const deps = testCreateDeps(); diff --git a/.gitignore b/.gitignore index 6407a81ea..1feba9ddb 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ test-identicons coverage tmp __screenshots__ +*.tsBuildInfo diff --git a/.vscode/settings.json b/.vscode/settings.json index 48fbce273..61b3d248c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -44,5 +44,11 @@ "**/coverage": true, "**/out": true }, - "vitest.disableWorkspaceWarning": true + "vitest.disableWorkspaceWarning": true, + "json.schemas": [ + { + "fileMatch": ["turbo.json"], + "url": "https://turborepo.dev/schema.json" + } + ] } diff --git a/apps/relay/package.json b/apps/relay/package.json index 4c9645d8d..395dcf8a8 100644 --- a/apps/relay/package.json +++ b/apps/relay/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "node --experimental-strip-types src/index.ts", "build": "rimraf dist && tsc", - "start": "node dist/index.js", + "start": "node dist/src/index.js", "clean": "rimraf .turbo node_modules dist data/evolu-relay.db" }, "files": [ diff --git a/examples/react-expo/package.json b/examples/react-expo/package.json index 2065706b6..d705be8eb 100644 --- a/examples/react-expo/package.json +++ b/examples/react-expo/package.json @@ -11,7 +11,7 @@ "ios": "expo run:ios", "ios:go": "expo start --ios", "lint": "expo lint", - "start": "expo start" + "_start": "expo start" }, "jest": { "preset": "jest-expo" diff --git a/package.json b/package.json index c7a5b0c5e..fe8b797e1 100755 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "examples/*" ], "scripts": { - "dev": "turbo watch dev --filter @evolu/* --filter web --concurrency=11", + "dev": "turbo --filter @evolu/relay --filter web dev", + "relay": "turbo --filter @evolu/relay dev", "build": "turbo --filter @evolu/* build", "build:web": "bun run build:docs && turbo --filter web build", "build:docs": "typedoc && bun --filter=web run fix:docs", diff --git a/packages/common/package.json b/packages/common/package.json index c7d089304..313c11b42 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -43,8 +43,7 @@ "README.md" ], "scripts": { - "dev": "tsc", - "build": "rimraf dist && tsc", + "build": "rimraf dist && tsc --build", "clean": "rimraf .turbo node_modules dist coverage test/tmp test/__screenshots__", "bench": "vitest bench" }, diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index 5705c4dcd..93bb419ab 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -4,7 +4,8 @@ "outDir": "dist", "rootDir": ".", "allowJs": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "tsBuildInfoFile": "dist/.tsBuildInfo" }, "include": ["src", "test", "vitest.*.config.ts"], "exclude": ["dist", "node_modules", "test/tmp"] diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index c2e0a513c..4fa3e913c 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -20,8 +20,7 @@ "README.md" ], "scripts": { - "dev": "tsc", - "build": "rimraf dist && tsc", + "build": "rimraf dist && tsc --build", "clean": "rimraf .turbo node_modules dist coverage" }, "dependencies": { diff --git a/packages/nodejs/tsconfig.json b/packages/nodejs/tsconfig.json index 3be7c513f..4f79710d2 100644 --- a/packages/nodejs/tsconfig.json +++ b/packages/nodejs/tsconfig.json @@ -2,8 +2,10 @@ "extends": "../tsconfig/universal-esm.json", "compilerOptions": { "outDir": "dist", + "rootDir": ".", "allowJs": true }, "include": ["src", "test", "vitest.config.ts"], - "exclude": ["dist", "node_modules", "test/tmp"] + "exclude": ["dist", "node_modules", "test/tmp"], + "references": [{ "path": "../common" }] } diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 38e893c8f..136f77bf4 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -65,8 +65,7 @@ "README.md" ], "scripts": { - "dev": "tsc", - "build": "rimraf dist && tsc", + "build": "rimraf dist && tsc --build", "clean": "rimraf .turbo node_modules dist coverage" }, "devDependencies": { diff --git a/packages/react-native/tsconfig.json b/packages/react-native/tsconfig.json index 1f88b5ee0..e41b2eae5 100644 --- a/packages/react-native/tsconfig.json +++ b/packages/react-native/tsconfig.json @@ -2,10 +2,13 @@ "extends": "../tsconfig/universal-esm.json", "compilerOptions": { "outDir": "dist", + "rootDir": ".", "allowJs": true, "module": "esnext", - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "tsBuildInfoFile": "dist/.tsBuildInfo" }, "include": ["src", "test", "vitest.config.ts"], - "exclude": ["dist", "node_modules"] + "exclude": ["dist", "node_modules"], + "references": [{ "path": "../common" }, { "path": "../react" }] } diff --git a/packages/react-web/package.json b/packages/react-web/package.json index 26f9975d0..ff997da7a 100644 --- a/packages/react-web/package.json +++ b/packages/react-web/package.json @@ -30,9 +30,9 @@ "README.md" ], "scripts": { - "build": "rimraf dist && tsc", + "build": "rimraf dist && tsc --build", "clean": "rimraf .turbo node_modules dist", - "dev": "tsc" + "dev": "tsc --build" }, "devDependencies": { "@evolu/common": "workspace:*", diff --git a/packages/react-web/src/index.ts b/packages/react-web/src/index.ts index 8c122e3af..720a51a7f 100644 --- a/packages/react-web/src/index.ts +++ b/packages/react-web/src/index.ts @@ -1,11 +1,2 @@ -import type { EvoluDeps } from "@evolu/common/local-first"; -import { createEvoluDeps as createWebEvoluDeps } from "@evolu/web"; -import { flushSync } from "react-dom"; - export * from "./components/index.js"; - -/** Creates Evolu dependencies for React web with React DOM flush sync support. */ -export const createEvoluDeps = (): EvoluDeps => { - const deps = createWebEvoluDeps(); - return { ...deps, flushSync }; -}; +export * from "./local-first/Evolu.js"; diff --git a/packages/react-web/tsconfig.json b/packages/react-web/tsconfig.json index 0d159af30..e7b4ec587 100644 --- a/packages/react-web/tsconfig.json +++ b/packages/react-web/tsconfig.json @@ -2,8 +2,12 @@ "extends": "../tsconfig/universal-esm.json", "compilerOptions": { "outDir": "dist", - "allowJs": true + "rootDir": "src", + "allowJs": true, + "types": ["../../node_modules/user-agent-data-types"], + "tsBuildInfoFile": "dist/.tsBuildInfo" }, - "include": ["src", "test"], - "exclude": ["dist", "node_modules"] + "include": ["src"], + "exclude": ["dist", "node_modules"], + "references": [{ "path": "../common" }, { "path": "../web" }] } diff --git a/packages/react/package.json b/packages/react/package.json index 907559688..400ae3e04 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -30,8 +30,7 @@ "README.md" ], "scripts": { - "dev": "tsc", - "build": "rimraf dist && tsc", + "build": "rimraf dist && tsc --build", "clean": "rimraf .turbo node_modules dist" }, "devDependencies": { diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 0d159af30..66aa82e1b 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -2,8 +2,11 @@ "extends": "../tsconfig/universal-esm.json", "compilerOptions": { "outDir": "dist", - "allowJs": true + "rootDir": "src", + "allowJs": true, + "tsBuildInfoFile": "dist/.tsBuildInfo" }, - "include": ["src", "test"], - "exclude": ["dist", "node_modules"] + "include": ["src"], + "exclude": ["dist", "node_modules"], + "references": [{ "path": "../common" }] } diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 922e49c18..646ece073 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -28,10 +28,10 @@ "!dist/**/*.spec.*" ], "scripts": { - "build": "rimraf dist && tsc && bun run package", + "build": "rimraf dist && tsc --build && bun run package", "check": "svelte-check --tsconfig ./tsconfig.json", "clean": "rimraf .turbo .svelte-kit node_modules dist", - "dev": "tsc", + "dev": "tsc --build", "package": "svelte-package", "prepublishOnly": "bun run package", "preview": "vite preview" diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json index 2b3420bda..9f9d445bd 100644 --- a/packages/svelte/tsconfig.json +++ b/packages/svelte/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@tsconfig/svelte/tsconfig.json", "compilerOptions": { "outDir": "dist", + "rootDir": "src", "allowJs": true, "checkJs": true, "esModuleInterop": true, @@ -11,8 +12,14 @@ "sourceMap": true, "strict": true, "module": "NodeNext", - "moduleResolution": "NodeNext" + "moduleResolution": "NodeNext", + "composite": true, + "incremental": true, + "declaration": true, + "declarationMap": true, + "tsBuildInfoFile": "dist/.tsBuildInfo" }, "include": ["src", "test", "svelte-types.d.ts"], - "exclude": ["dist", "node_modules"] + "exclude": ["dist", "node_modules"], + "references": [{ "path": "../common" }, { "path": "../web" }] } diff --git a/packages/tsconfig.json b/packages/tsconfig.json new file mode 100644 index 000000000..c302d623c --- /dev/null +++ b/packages/tsconfig.json @@ -0,0 +1,13 @@ +{ + "files": [], + "references": [ + { "path": "common" }, + { "path": "web" }, + { "path": "nodejs" }, + { "path": "react" }, + { "path": "react-web" }, + { "path": "react-native" }, + { "path": "svelte" }, + { "path": "vue" } + ] +} diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json index aacbd2dd3..6dfc86a30 100644 --- a/packages/tsconfig/base.json +++ b/packages/tsconfig/base.json @@ -2,7 +2,8 @@ "$schema": "https://json.schemastore.org/tsconfig", "display": "Default", "compilerOptions": { - "composite": false, + "composite": true, + "incremental": true, "declaration": true, "declarationMap": true, "esModuleInterop": true, diff --git a/packages/vue/package.json b/packages/vue/package.json index 4a0084fc9..df16cd572 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -30,8 +30,7 @@ "README.md" ], "scripts": { - "dev": "tsc", - "build": "rimraf dist && tsc", + "build": "rimraf dist && tsc --build", "clean": "rimraf .turbo node_modules dist" }, "devDependencies": { diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json index aba7f39bc..625d7c15d 100644 --- a/packages/vue/tsconfig.json +++ b/packages/vue/tsconfig.json @@ -3,8 +3,11 @@ "extends": "@evolu/tsconfig/universal-esm.json", "compilerOptions": { "outDir": "dist", - "allowJs": true + "rootDir": "src", + "allowJs": true, + "tsBuildInfoFile": "dist/.tsBuildInfo" }, - "include": ["src", "test"], - "exclude": ["dist", "node_modules"] + "include": ["src"], + "exclude": ["dist", "node_modules"], + "references": [{ "path": "../common" }] } diff --git a/packages/web/package.json b/packages/web/package.json index ae320baa6..14e38a3b9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -28,8 +28,8 @@ "README.md" ], "scripts": { - "dev": "tsc", - "build": "rimraf dist && tsc", + "build": "rimraf dist && tsc --build", + "dev": "tsc --build", "clean": "rimraf .turbo node_modules dist coverage" }, "dependencies": { diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index 76548e689..1e3b4e173 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -2,8 +2,12 @@ "extends": "../tsconfig/universal-esm.json", "compilerOptions": { "outDir": "dist", - "allowJs": true + "rootDir": ".", + "allowJs": true, + "types": ["../../node_modules/user-agent-data-types"], + "tsBuildInfoFile": "dist/.tsBuildInfo" }, "include": ["src", "test", "vitest.config.ts"], - "exclude": ["dist", "node_modules"] + "exclude": ["dist", "node_modules"], + "references": [{ "path": "../common" }] } diff --git a/turbo.json b/turbo.json index 6bf2d0cc0..0f22adaf1 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turborepo.org/schema.json", + "$schema": "https://turborepo.dev/schema.json", "globalEnv": ["NODE_ENV", "PORT", "ENABLE_EXPERIMENTAL_COREPACK"], "tasks": { "build": { @@ -12,7 +12,6 @@ "dependsOn": ["^build"] }, "dev": { - "dependsOn": ["^dev"], "cache": false }, "relay#dev": { From 1546fc86889322c985add4ee4479d1496269b64c Mon Sep 17 00:00:00 2001 From: Miccy Date: Sat, 7 Feb 2026 20:37:37 +0100 Subject: [PATCH 05/37] fix(sync): keep build green after f79f9f86 integration --- packages/react-web/src/index.ts | 11 ++++++++++- packages/react-web/tsconfig.json | 1 - packages/web/tsconfig.json | 1 - 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/react-web/src/index.ts b/packages/react-web/src/index.ts index 720a51a7f..8c122e3af 100644 --- a/packages/react-web/src/index.ts +++ b/packages/react-web/src/index.ts @@ -1,2 +1,11 @@ +import type { EvoluDeps } from "@evolu/common/local-first"; +import { createEvoluDeps as createWebEvoluDeps } from "@evolu/web"; +import { flushSync } from "react-dom"; + export * from "./components/index.js"; -export * from "./local-first/Evolu.js"; + +/** Creates Evolu dependencies for React web with React DOM flush sync support. */ +export const createEvoluDeps = (): EvoluDeps => { + const deps = createWebEvoluDeps(); + return { ...deps, flushSync }; +}; diff --git a/packages/react-web/tsconfig.json b/packages/react-web/tsconfig.json index e7b4ec587..537a5a15a 100644 --- a/packages/react-web/tsconfig.json +++ b/packages/react-web/tsconfig.json @@ -4,7 +4,6 @@ "outDir": "dist", "rootDir": "src", "allowJs": true, - "types": ["../../node_modules/user-agent-data-types"], "tsBuildInfoFile": "dist/.tsBuildInfo" }, "include": ["src"], diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index 1e3b4e173..6a4e9e6f9 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -4,7 +4,6 @@ "outDir": "dist", "rootDir": ".", "allowJs": true, - "types": ["../../node_modules/user-agent-data-types"], "tsBuildInfoFile": "dist/.tsBuildInfo" }, "include": ["src", "test", "vitest.config.ts"], From 3bdb958dac27df9c15281db33c10f1ac5e9f5aa6 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Thu, 5 Feb 2026 19:46:20 +0100 Subject: [PATCH 06/37] Enable ESLint cache and remove monorepo note (cherry picked from commit 585a79a7b2fa9fa5b796fbead62531a652fe3587) --- .github/copilot-instructions.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7975034d0..a13c44406 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -648,11 +648,6 @@ bun run test --filter @evolu/common -- Task # Run a single test by name (-t flag) bun run test --filter @evolu/common -- -t "yields and returns ok" ``` - -## Monorepo TypeScript issues - -**TypeScript "Unsafe..." errors after changes** - In a monorepo, you may see "Unsafe call", "Unsafe member access", or "Unsafe assignment" errors after modifying packages that other packages depend on. These are TypeScript language server errors, not Biome linting errors. They should be investigated but may be false positives. Solution: use VS Code's "Developer: Reload Window" command (Cmd+Shift+P) to refresh the TypeScript language server. - ## Git commit messages - **Write as sentences** - use proper sentence case without trailing period From 820e8d6c1d2d15e3c4c0a5a378b31629153a28e2 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 11:44:38 +0100 Subject: [PATCH 07/37] Update pnpm-lock.yaml (cherry picked from commit cadf5c0d64f2919854b3db5110188505ef0f4941) # Conflicts: # pnpm-lock.yaml From 7643c782c8a67781daa1c92bbf5e533a6b2abdd1 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 11:50:06 +0100 Subject: [PATCH 08/37] Cleaned up tsconfig files across packages Removed allowJs (not needed since all source is TypeScript), removed moduleResolution: "node" from base tsconfig, removed redundant exclude: ["node_modules"] from base, fixed rootDir from "src" to "." where needed, and added test + vitest config to include arrays (cherry picked from commit 6cf887036c3b6a62a81a7f717d60a3bfa25d1fa7) --- packages/common/tsconfig.json | 1 - packages/nodejs/tsconfig.json | 2 +- packages/react-native/tsconfig.json | 1 - packages/react-web/tsconfig.json | 5 ++--- packages/react/tsconfig.json | 5 ++--- packages/tsconfig/base.json | 5 ++--- packages/tsconfig/nextjs.json | 15 +++------------ packages/vue/tsconfig.json | 5 ++--- packages/web/tsconfig.json | 1 - 9 files changed, 12 insertions(+), 28 deletions(-) diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index 93bb419ab..4fcf55089 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "outDir": "dist", "rootDir": ".", - "allowJs": true, "resolveJsonModule": true, "tsBuildInfoFile": "dist/.tsBuildInfo" }, diff --git a/packages/nodejs/tsconfig.json b/packages/nodejs/tsconfig.json index 4f79710d2..c3da2b913 100644 --- a/packages/nodejs/tsconfig.json +++ b/packages/nodejs/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "dist", "rootDir": ".", - "allowJs": true + "tsBuildInfoFile": "dist/.tsBuildInfo" }, "include": ["src", "test", "vitest.config.ts"], "exclude": ["dist", "node_modules", "test/tmp"], diff --git a/packages/react-native/tsconfig.json b/packages/react-native/tsconfig.json index e41b2eae5..11e219200 100644 --- a/packages/react-native/tsconfig.json +++ b/packages/react-native/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "outDir": "dist", "rootDir": ".", - "allowJs": true, "module": "esnext", "moduleResolution": "bundler", "tsBuildInfoFile": "dist/.tsBuildInfo" diff --git a/packages/react-web/tsconfig.json b/packages/react-web/tsconfig.json index 537a5a15a..7c867cdbc 100644 --- a/packages/react-web/tsconfig.json +++ b/packages/react-web/tsconfig.json @@ -2,11 +2,10 @@ "extends": "../tsconfig/universal-esm.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src", - "allowJs": true, + "rootDir": ".", "tsBuildInfoFile": "dist/.tsBuildInfo" }, - "include": ["src"], + "include": ["src", "test", "vitest.config.ts"], "exclude": ["dist", "node_modules"], "references": [{ "path": "../common" }, { "path": "../web" }] } diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 66aa82e1b..3dd3a43c2 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -2,11 +2,10 @@ "extends": "../tsconfig/universal-esm.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src", - "allowJs": true, + "rootDir": ".", "tsBuildInfoFile": "dist/.tsBuildInfo" }, - "include": ["src"], + "include": ["src", "test", "vitest.config.ts"], "exclude": ["dist", "node_modules"], "references": [{ "path": "../common" }] } diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json index 6dfc86a30..56a1a2fa5 100644 --- a/packages/tsconfig/base.json +++ b/packages/tsconfig/base.json @@ -10,7 +10,6 @@ "forceConsistentCasingInFileNames": true, "inlineSources": false, "verbatimModuleSyntax": true, - "moduleResolution": "node", "noUnusedLocals": false, "noUnusedParameters": false, "preserveWatchOutput": true, @@ -18,7 +17,7 @@ "strict": true, "exactOptionalPropertyTypes": true, "noErrorTruncation": false, + "erasableSyntaxOnly": true, "typeRoots": ["node_modules/@types", "../../node_modules/@types"] - }, - "exclude": ["node_modules"] + } } diff --git a/packages/tsconfig/nextjs.json b/packages/tsconfig/nextjs.json index 882daed99..437afa944 100644 --- a/packages/tsconfig/nextjs.json +++ b/packages/tsconfig/nextjs.json @@ -3,23 +3,14 @@ "display": "Next.js", "extends": "./base.json", "compilerOptions": { - "target": "ES2017", + "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, - "skipLibCheck": true, - "strict": true, "noEmit": true, - "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "paths": { - "@/*": ["./*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "jsx": "preserve" + } } diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json index 625d7c15d..953141420 100644 --- a/packages/vue/tsconfig.json +++ b/packages/vue/tsconfig.json @@ -3,11 +3,10 @@ "extends": "@evolu/tsconfig/universal-esm.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src", - "allowJs": true, + "rootDir": ".", "tsBuildInfoFile": "dist/.tsBuildInfo" }, - "include": ["src"], + "include": ["src", "test", "vitest.config.ts"], "exclude": ["dist", "node_modules"], "references": [{ "path": "../common" }] } diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index 6a4e9e6f9..3dd3a43c2 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "outDir": "dist", "rootDir": ".", - "allowJs": true, "tsBuildInfoFile": "dist/.tsBuildInfo" }, "include": ["src", "test", "vitest.config.ts"], From 09204537f50b493a30f83b61bfa911a4f8ccef9a Mon Sep 17 00:00:00 2001 From: Miccy Date: Sat, 7 Feb 2026 20:45:40 +0100 Subject: [PATCH 09/37] fix(sync): keep package build outputs stable after 6cf88703 --- packages/react-web/tsconfig.json | 4 ++-- packages/react/tsconfig.json | 4 ++-- packages/vue/tsconfig.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react-web/tsconfig.json b/packages/react-web/tsconfig.json index 7c867cdbc..e25f81532 100644 --- a/packages/react-web/tsconfig.json +++ b/packages/react-web/tsconfig.json @@ -2,10 +2,10 @@ "extends": "../tsconfig/universal-esm.json", "compilerOptions": { "outDir": "dist", - "rootDir": ".", + "rootDir": "src", "tsBuildInfoFile": "dist/.tsBuildInfo" }, - "include": ["src", "test", "vitest.config.ts"], + "include": ["src"], "exclude": ["dist", "node_modules"], "references": [{ "path": "../common" }, { "path": "../web" }] } diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 3dd3a43c2..389c90349 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -2,10 +2,10 @@ "extends": "../tsconfig/universal-esm.json", "compilerOptions": { "outDir": "dist", - "rootDir": ".", + "rootDir": "src", "tsBuildInfoFile": "dist/.tsBuildInfo" }, - "include": ["src", "test", "vitest.config.ts"], + "include": ["src"], "exclude": ["dist", "node_modules"], "references": [{ "path": "../common" }] } diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json index 953141420..ff00bf992 100644 --- a/packages/vue/tsconfig.json +++ b/packages/vue/tsconfig.json @@ -3,10 +3,10 @@ "extends": "@evolu/tsconfig/universal-esm.json", "compilerOptions": { "outDir": "dist", - "rootDir": ".", + "rootDir": "src", "tsBuildInfoFile": "dist/.tsBuildInfo" }, - "include": ["src", "test", "vitest.config.ts"], + "include": ["src"], "exclude": ["dist", "node_modules"], "references": [{ "path": "../common" }] } From 40703789abfdcf6e720e64e3337f5073fbd516c1 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 11:50:52 +0100 Subject: [PATCH 10/37] Fixed package.json export paths to include src directory Updated types, exports, and files fields from dist/ to dist/src/ to match the new rootDir: "." tsconfig layout (cherry picked from commit bba6488d276f2165fcd6805f63fcfc9cfe3519cb) --- packages/nodejs/package.json | 5 ++++- packages/react-web/package.json | 10 +++++----- packages/react/package.json | 12 ++++++------ packages/vue/package.json | 10 +++++----- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index 4fa3e913c..72e1e5ffd 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -12,7 +12,10 @@ "type": "module", "types": "./dist/src/index.d.ts", "exports": { - ".": "./dist/src/index.js" + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } }, "files": [ "dist/src/**", diff --git a/packages/react-web/package.json b/packages/react-web/package.json index ff997da7a..0a24ea46e 100644 --- a/packages/react-web/package.json +++ b/packages/react-web/package.json @@ -18,14 +18,14 @@ "type": "module", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "browser": "./dist/index.js" + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js", + "browser": "./dist/src/index.js" } }, - "types": "./dist/index.d.ts", + "types": "./dist/src/index.d.ts", "files": [ - "dist/**", + "dist/src/**", "src/**", "README.md" ], diff --git a/packages/react/package.json b/packages/react/package.json index 400ae3e04..477d2f9e8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -15,17 +15,17 @@ }, "homepage": "https://evolu.dev", "type": "module", - "types": "./dist/index.d.ts", + "types": "./dist/src/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "browser": "./dist/index.js", - "react-native": "./dist/index.js" + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js", + "browser": "./dist/src/index.js", + "react-native": "./dist/src/index.js" } }, "files": [ - "dist/**", + "dist/src/**", "src/**", "README.md" ], diff --git a/packages/vue/package.json b/packages/vue/package.json index df16cd572..ddf5d9c8f 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -18,14 +18,14 @@ "type": "module", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "browser": "./dist/index.js" + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js", + "browser": "./dist/src/index.js" } }, - "types": "./dist/index.d.ts", + "types": "./dist/src/index.d.ts", "files": [ - "dist/**", + "dist/src/**", "src/**", "README.md" ], From 8f9416ff83aa721dde946dcfdf2a04232bd1d548 Mon Sep 17 00:00:00 2001 From: Miccy Date: Sat, 7 Feb 2026 20:47:31 +0100 Subject: [PATCH 11/37] fix(sync): keep package export targets at dist root --- packages/react-web/package.json | 10 +++++----- packages/react/package.json | 12 ++++++------ packages/vue/package.json | 10 +++++----- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/react-web/package.json b/packages/react-web/package.json index 0a24ea46e..ff997da7a 100644 --- a/packages/react-web/package.json +++ b/packages/react-web/package.json @@ -18,14 +18,14 @@ "type": "module", "exports": { ".": { - "types": "./dist/src/index.d.ts", - "import": "./dist/src/index.js", - "browser": "./dist/src/index.js" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "browser": "./dist/index.js" } }, - "types": "./dist/src/index.d.ts", + "types": "./dist/index.d.ts", "files": [ - "dist/src/**", + "dist/**", "src/**", "README.md" ], diff --git a/packages/react/package.json b/packages/react/package.json index 477d2f9e8..400ae3e04 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -15,17 +15,17 @@ }, "homepage": "https://evolu.dev", "type": "module", - "types": "./dist/src/index.d.ts", + "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./dist/src/index.d.ts", - "import": "./dist/src/index.js", - "browser": "./dist/src/index.js", - "react-native": "./dist/src/index.js" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "browser": "./dist/index.js", + "react-native": "./dist/index.js" } }, "files": [ - "dist/src/**", + "dist/**", "src/**", "README.md" ], diff --git a/packages/vue/package.json b/packages/vue/package.json index ddf5d9c8f..df16cd572 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -18,14 +18,14 @@ "type": "module", "exports": { ".": { - "types": "./dist/src/index.d.ts", - "import": "./dist/src/index.js", - "browser": "./dist/src/index.js" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "browser": "./dist/index.js" } }, - "types": "./dist/src/index.d.ts", + "types": "./dist/index.d.ts", "files": [ - "dist/src/**", + "dist/**", "src/**", "README.md" ], From b1d5be6eccb6e3b530410a13bcaeef5d765b7462 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 11:51:56 +0100 Subject: [PATCH 12/37] Added build dependency to turbo dev task Added dependsOn: ["^build"] so dependent packages are built before starting dev servers (cherry picked from commit 9950864381c447a489cdf857bf49be5b1f1de14b) --- turbo.json | 1 + 1 file changed, 1 insertion(+) diff --git a/turbo.json b/turbo.json index 0f22adaf1..cbed007b5 100644 --- a/turbo.json +++ b/turbo.json @@ -12,6 +12,7 @@ "dependsOn": ["^build"] }, "dev": { + "dependsOn": ["^build"], "cache": false }, "relay#dev": { From da98dbd721dc11e75f7028cf4072612fc82ed1b0 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 11:53:06 +0100 Subject: [PATCH 13/37] Improved createRun JSDoc across platforms Consistent wording pattern "Creates Run for X with Y" and expanded descriptions for Node.js, web, and React Native createRun functions. Removed redundant JSDoc from CreateRun interface (cherry picked from commit 0618f54995508ab34db2670188488d146f4b05cb) --- packages/common/src/Task.ts | 5 +---- packages/nodejs/src/Task.ts | 11 ++++++----- packages/react-native/src/Task.ts | 2 +- packages/web/src/Task.ts | 2 +- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/common/src/Task.ts b/packages/common/src/Task.ts index 8c175e1a0..20e7b60e4 100644 --- a/packages/common/src/Task.ts +++ b/packages/common/src/Task.ts @@ -1193,15 +1193,12 @@ const defaultDeps: RunnerDeps = { * @group Creating Runners */ export interface CreateRunner { - /** With default dependencies only. */ (): Runner; - - /** With custom dependencies merged into base deps. */ (deps: D): Runner; } /** - * Creates a root {@link Runner}. + * Creates root {@link Runner}. * * Call once per entry point (main thread, worker, etc.) and dispose on * shutdown. All tasks run as descendants of this root runner. diff --git a/packages/nodejs/src/Task.ts b/packages/nodejs/src/Task.ts index e655dabeb..721c6c950 100644 --- a/packages/nodejs/src/Task.ts +++ b/packages/nodejs/src/Task.ts @@ -28,12 +28,13 @@ export interface ShutdownDep { } /** - * Creates a Node.js {@link Runner} with error handling and shutdown signal. + * Creates {@link Runner} for Node.js with global error handling and graceful + * shutdown. * - * - Global error handlers (`uncaughtException`, `unhandledRejection`) that log - * errors and initiate graceful shutdown - * - A `shutdown` promise in deps that resolves on termination signals (`SIGINT`, - * `SIGTERM`, `SIGHUP`, `SIGBREAK`) + * Registers `uncaughtException` and `unhandledRejection` handlers that log + * errors and initiate graceful shutdown. Adds a `shutdown` promise to deps that + * resolves on termination signals (`SIGINT`, `SIGTERM`, `SIGHUP`). Handlers are + * removed when the Runner is disposed. * * ### Example * diff --git a/packages/react-native/src/Task.ts b/packages/react-native/src/Task.ts index 9a24a510a..e24d27f0c 100644 --- a/packages/react-native/src/Task.ts +++ b/packages/react-native/src/Task.ts @@ -13,7 +13,7 @@ import { } from "@evolu/common"; /** - * Creates a React Native {@link Runner} with global error handling. + * Creates {@link Runner} for React Native with global error handling. * * Registers `ErrorUtils.setGlobalHandler` for uncaught JavaScript errors. The * handler is restored to the previous one when the runner is disposed. diff --git a/packages/web/src/Task.ts b/packages/web/src/Task.ts index 9cddff8c9..f34710b48 100644 --- a/packages/web/src/Task.ts +++ b/packages/web/src/Task.ts @@ -13,7 +13,7 @@ import { } from "@evolu/common"; /** - * Creates a browser {@link Runner} with global error handling. + * Creates {@link Runner} for the browser with global error handling. * * Registers `error` and `unhandledrejection` handlers that log errors to the * console. Handlers are removed when the runner is disposed. From 64f583bc974ccc560c4610791b80a4f4787bafbe Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 11:53:44 +0100 Subject: [PATCH 14/37] Sorted imports in Task.test.ts (cherry picked from commit 30ee124f4863fdab9ae47c817f304ed57a3d1b82) # Conflicts: # packages/common/test/Task.test.ts From 752e12acc7979b25ab3a238f148442df69ec2603 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 11:54:29 +0100 Subject: [PATCH 15/37] Rewrote Evolu as Task-based createEvolu Replaced class-style creation with Task-based createEvolu returning Task, never, EvoluPlatformDeps>. Simplified Evolu interface: made AsyncDisposable, changed appOwner from Promise to direct value, renamed externalAppOwner config to appOwner, commented out mutations and error store pending reimplementation. Added tests for new API (cherry picked from commit eff36aaf3e838a4802f9a5027599850574d5cd53) --- packages/common/src/local-first/Evolu.ts | 1786 ++++++++--------- .../common/test/local-first/Evolu.test.ts | 55 +- 2 files changed, 932 insertions(+), 909 deletions(-) diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index da368ac5a..833985eee 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -4,55 +4,27 @@ * @module */ -import { pack } from "msgpackr"; -import { dedupeArray, isNonEmptyArray } from "../Array.js"; -import { assert, assertNonEmptyReadonlyArray } from "../Assert.js"; -import { createCallbacks } from "../Callbacks.js"; -import type { ConsoleDep } from "../Console.js"; -import { createConsole } from "../Console.js"; -import type { RandomBytesDep } from "../Crypto.js"; -import { createRandomBytes } from "../Crypto.js"; -import { eqArrayNumber } from "../Eq.js"; import type { Listener, Unsubscribe } from "../Listeners.js"; +import { ok } from "../Result.js"; +import type { Task } from "../Task.js"; import type { FlushSyncDep, ReloadAppDep } from "../Platform.js"; -import type { DisposableDep, DisposableStackDep } from "../Resources.js"; -import { createDisposableDep } from "../Resources.js"; -import type { Result } from "../Result.js"; -import { err, ok } from "../Result.js"; -import { SqliteBoolean, sqliteBooleanToBoolean } from "../Sqlite.js"; -import type { ReadonlyStore, Store } from "../Store.js"; -import { createStore } from "../Store.js"; -import type { AnyType, InferErrors, InferInput, ObjectType } from "../Type.js"; -import { createId, type Id, type SimpleName } from "../Type.js"; -import type { CreateMessageChannelDep } from "../Worker.js"; -import type { EvoluError } from "./Error.js"; +import { SimpleName } from "../Type.js"; import type { AppOwner, OwnerTransport } from "./Owner.js"; -import { createOwnerWebSocketTransport, OwnerId } from "./Owner.js"; +import { + createAppOwner, + createOwnerSecret, + createOwnerWebSocketTransport, + OwnerId, +} from "./Owner.js"; import type { Queries, QueriesToQueryRowsPromises, Query, QueryRows, - QueryRowsMap, Row, - SubscribedQueries, } from "./Query.js"; -import { createSubscribedQueries, emptyRows } from "./Query.js"; -import type { - EvoluSchema, - IndexesConfig, - Mutation, - MutationChange, - MutationKind, - MutationMapping, - MutationOptions, - SystemColumns, - ValidateSchema, -} from "./Schema.js"; -import { insertable, updateable, upsertable } from "./Schema.js"; -import { DbChange } from "./Storage.js"; +import type { EvoluSchema, IndexesConfig, ValidateSchema } from "./Schema.js"; import type { SyncOwner } from "./Sync.js"; -import type { EvoluWorkerDep } from "./Worker.js"; export interface EvoluConfig { /** @@ -71,6 +43,50 @@ export interface EvoluConfig { */ readonly name: SimpleName; + /** + * External AppOwner to use when creating Evolu instance. Use this when you + * want to manage AppOwner creation and persistence externally (e.g., with + * your own authentication system). If omitted, Evolu will automatically + * create and persist an AppOwner locally. + * + * For device-specific settings and account management state, we can use a + * separate local-only Evolu instance via `transports: []`. + * + * ### Example + * + * ```ts + * const ConfigId = id("Config"); + * type ConfigId = typeof ConfigId.Type; + * + * const DeviceSchema = { + * config: { + * id: ConfigId, + * key: NonEmptyString50, + * value: NonEmptyString50, + * }, + * }; + * + * // Local-only instance for device settings (no sync) + * const deviceEvolu = createEvolu(evoluReactWebDeps)(DeviceSchema, { + * name: SimpleName.orThrow("MyApp-Device"), + * transports: [], // No sync - stays local to device + * }); + * + * // Main synced instance for user data + * const evolu = createEvolu(evoluReactWebDeps)(MainSchema, { + * name: SimpleName.orThrow("MyApp"), + * // Default transports for sync + * }); + * ``` + */ + readonly appOwner?: AppOwner; + + /** + * @deprecated Use {@link EvoluConfig.appOwner}. Kept for transitional + * backward compatibility in downstream apps. + */ + readonly externalAppOwner?: AppOwner; + /** * Transport configuration for data sync and backup. Supports single transport * or multiple transports simultaneously for redundancy. @@ -128,44 +144,6 @@ export interface EvoluConfig { */ readonly transports?: ReadonlyArray; - /** - * External AppOwner to use when creating Evolu instance. Use this when you - * want to manage AppOwner creation and persistence externally (e.g., with - * your own authentication system). If omitted, Evolu will automatically - * create and persist an AppOwner locally. - * - * For device-specific settings and account management state, we can use a - * separate local-only Evolu instance via `transports: []`. - * - * ### Example - * - * ```ts - * const ConfigId = id("Config"); - * type ConfigId = typeof ConfigId.Type; - * - * const DeviceSchema = { - * config: { - * id: ConfigId, - * key: NonEmptyString50, - * value: NonEmptyString50, - * }, - * }; - * - * // Local-only instance for device settings (no sync) - * const deviceEvolu = createEvolu(evoluReactWebDeps)(DeviceSchema, { - * name: SimpleName.orThrow("MyApp-Device"), - * transports: [], // No sync - stays local to device - * }); - * - * // Main synced instance for user data - * const evolu = createEvolu(evoluReactWebDeps)(MainSchema, { - * name: SimpleName.orThrow("MyApp"), - * // Default transports for sync - * }); - * ``` - */ - readonly externalAppOwner?: AppOwner; - /** * Use in-memory SQLite database instead of persistent storage. Useful for * testing or temporary data that doesn't need persistence. @@ -199,41 +177,31 @@ export interface EvoluConfig { */ readonly indexes?: IndexesConfig; - /** - * URL to reload browser tabs after reset or restore. - * - * The default value is `/`. - */ - readonly reloadAppUrl?: string; + // /** + // * URL to reload browser tabs after reset or restore. + // * + // * The default value is `/`. + // */ + // readonly reloadAppUrl?: string; } -export interface Evolu extends Disposable { +/** Local-first SQL database with typed queries, mutations, and sync. */ +export interface Evolu< + // eslint-disable-next-line @typescript-eslint/no-unused-vars + S extends EvoluSchema = EvoluSchema, +> extends AsyncDisposable { /** The name of the Evolu instance from {@link EvoluConfig}. */ readonly name: SimpleName; - /** - * Subscribe to {@link EvoluError} changes. - * - * ### Example - * - * ```ts - * const unsubscribe = evolu.subscribeError(() => { - * const error = evolu.getError(); - * console.log(error); - * }); - * ``` - */ - readonly subscribeError: (listener: Listener) => Unsubscribe; - - /** Get {@link EvoluError}. */ - readonly getError: () => EvoluError | null; + /** {@link AppOwner}. */ + readonly appOwner: AppOwner; /** * Load {@link Query} and return a promise with {@link QueryRows}. * * The returned promise always resolves successfully because there is no - * reason why loading should fail. All data are local, and the query is typed. - * Unexpected errors are handled with {@link Evolu.subscribeError}. + * reason why loading should fail. All data are local, and the query is + * typed. * * Loading is batched, and returned promises are cached until resolved to * prevent redundant database queries and to support React Suspense (which @@ -299,210 +267,196 @@ export interface Evolu extends Disposable { */ readonly getQueryRows: (query: Query) => QueryRows; - /** - * Promise that resolves to {@link AppOwner} when available. - * - * Note: With web-only deps, this promise will not resolve during SSR because - * there is no AppOwner on the server. - * - * ### Example - * - * ```ts - * const owner = await evolu.appOwner; - * ``` - */ - readonly appOwner: Promise; - - /** - * Inserts a row into the database and returns a {@link Result} with the new - * {@link Id}. - * - * The first argument is the table name, and the second is an object - * containing the row data. An optional third argument provides mutation - * options including an `onComplete` callback and `onlyValidate` flag. - * - * Returns a Result type - use `.ok` to check if the insertion succeeded, and - * `.value.id` to access the generated ID on success, or `.error` to handle - * validation errors. - * - * Evolu does not use SQL for mutations to ensure data can be safely and - * predictably merged without conflicts. Explicit mutations also allow Evolu - * to automatically update {@link SystemColumns}. - * - * ### Example - * - * ```ts - * const result = evolu.insert("todo", { - * title: "Learn Evolu", - * isCompleted: false, - * }); - * - * if (result.ok) { - * console.log("Todo created with ID:", result.value.id); - * } else { - * console.error("Validation error:", result.error); - * } - * - * // With onComplete callback - * evolu.insert( - * "todo", - * { title: "Another todo" }, - * { - * onComplete: () => { - * console.log("Insert completed"); - * }, - * }, - * ); - * ``` - */ - insert: Mutation; - - /** - * Updates a row in the database and returns a {@link Result} with the existing - * {@link Id}. - * - * The first argument is the table name, and the second is an object - * containing the row data including the required `id` field. An optional - * third argument provides mutation options including an `onComplete` callback - * and `onlyValidate` flag. - * - * Returns a Result type - use `.ok` to check if the update succeeded, and - * `.value.id` to access the ID on success, or `.error` to handle validation - * errors. - * - * Evolu does not use SQL for mutations to ensure data can be safely and - * predictably merged without conflicts. Explicit mutations also allow Evolu - * to automatically update {@link SystemColumns}. - * - * ### Example - * - * ```ts - * const result = evolu.update("todo", { - * id: todoId, - * title: "Updated title", - * isCompleted: true, - * }); - * - * if (result.ok) { - * console.log("Todo updated with ID:", result.value.id); - * } else { - * console.error("Validation error:", result.error); - * } - * - * // To delete a row, set isDeleted to true - * evolu.update("todo", { id: todoId, isDeleted: true }); - * - * // With onComplete callback - * evolu.update( - * "todo", - * { id: todoId, title: "New title" }, - * { - * onComplete: () => { - * console.log("Update completed"); - * }, - * }, - * ); - * ``` - */ - update: Mutation; - - /** - * Upserts a row in the database and returns a {@link Result} with the existing - * {@link Id}. - * - * The first argument is the table name, and the second is an object - * containing the row data including the required `id` field. An optional - * third argument provides mutation options including an `onComplete` callback - * and `onlyValidate` flag. - * - * This function allows you to use custom IDs and optionally set `createdAt`, - * which is useful for external systems, data migrations, or when the same row - * may already be created on a different device. - * - * Returns a Result type - use `.ok` to check if the upsert succeeded, and - * `.value.id` to access the ID on success, or `.error` to handle validation - * errors. - * - * Evolu does not use SQL for mutations to ensure data can be safely and - * predictably merged without conflicts. Explicit mutations also allow Evolu - * to automatically update {@link SystemColumns}. - * - * ### Example - * - * ```ts - * // Use deterministic ID for stable upserts across devices - * const stableId = createIdFromString("my-todo-1"); - * - * const result = evolu.upsert("todo", { - * id: stableId, - * title: "Learn Evolu", - * isCompleted: false, - * }); - * - * if (result.ok) { - * console.log("Todo upserted with ID:", result.value.id); - * } else { - * console.error("Validation error:", result.error); - * } - * - * // Data migration with custom createdAt - * evolu.upsert("todo", { - * id: externalId, - * title: "Migrated todo", - * createdAt: new Date("2023-01-01"), // Preserve original timestamp - * }); - * - * // With onComplete callback - * evolu.upsert( - * "todo", - * { id: stableId, title: "Updated title" }, - * { - * onComplete: () => { - * console.log("Upsert completed"); - * }, - * }, - * ); - * ``` - */ - upsert: Mutation; - // /** - // * Delete {@link AppOwner} and all their data from the current device. After - // * the deletion, Evolu will purge the application state. For browsers, this - // * will reload all tabs using Evolu. For native apps, it will restart the - // * app. + // * Inserts a row into the database and returns a {@link Result} with the new + // * {@link Id}. + // * + // * The first argument is the table name, and the second is an object + // * containing the row data. An optional third argument provides mutation + // * options including an `onComplete` callback and `onlyValidate` flag. + // * + // * Returns a Result type - use `.ok` to check if the insertion succeeded, and + // * `.value.id` to access the generated ID on success, or `.error` to handle + // * validation errors. // * - // * Reloading can be turned off via options if you want to provide a different - // * UX. + // * Evolu does not use SQL for mutations to ensure data can be safely and + // * predictably merged without conflicts. Explicit mutations also allow Evolu + // * to automatically update {@link SystemColumns}. + // * + // * ### Example + // * + // * ```ts + // * const result = evolu.insert("todo", { + // * title: "Learn Evolu", + // * isCompleted: false, + // * }); + // * + // * if (result.ok) { + // * console.log("Todo created with ID:", result.value.id); + // * } else { + // * console.error("Validation error:", result.error); + // * } + // * + // * // With onComplete callback + // * evolu.insert( + // * "todo", + // * { title: "Another todo" }, + // * { + // * onComplete: () => { + // * console.log("Insert completed"); + // * }, + // * }, + // * ); + // * ``` // */ - // readonly resetAppOwner: (options?: { - // readonly reload?: boolean; - // }) => Promise; + // insert: Mutation; // /** - // * Restore {@link AppOwner} with all their synced data. It uses - // * {@link Evolu.resetAppOwner}, so be careful. + // * Updates a row in the database and returns a {@link Result} with the existing + // * {@link Id}. + // * + // * The first argument is the table name, and the second is an object + // * containing the row data including the required `id` field. An optional + // * third argument provides mutation options including an `onComplete` callback + // * and `onlyValidate` flag. + // * + // * Returns a Result type - use `.ok` to check if the update succeeded, and + // * `.value.id` to access the ID on success, or `.error` to handle validation + // * errors. + // * + // * Evolu does not use SQL for mutations to ensure data can be safely and + // * predictably merged without conflicts. Explicit mutations also allow Evolu + // * to automatically update {@link SystemColumns}. + // * + // * ### Example + // * + // * ```ts + // * const result = evolu.update("todo", { + // * id: todoId, + // * title: "Updated title", + // * isCompleted: true, + // * }); + // * + // * if (result.ok) { + // * console.log("Todo updated with ID:", result.value.id); + // * } else { + // * console.error("Validation error:", result.error); + // * } + // * + // * // To delete a row, set isDeleted to true + // * evolu.update("todo", { id: todoId, isDeleted: true }); + // * + // * // With onComplete callback + // * evolu.update( + // * "todo", + // * { id: todoId, title: "New title" }, + // * { + // * onComplete: () => { + // * console.log("Update completed"); + // * }, + // * }, + // * ); + // * ``` // */ - // readonly restoreAppOwner: ( - // mnemonic: Mnemonic, - // options?: { - // readonly reload?: boolean; - // }, - // ) => Promise; + // update: Mutation; // /** - // * Reload the app in a platform-specific way. For browsers, this will reload - // * all tabs using Evolu. For native apps, it will restart the app. + // * Upserts a row in the database and returns a {@link Result} with the existing + // * {@link Id}. + // * + // * The first argument is the table name, and the second is an object + // * containing the row data including the required `id` field. An optional + // * third argument provides mutation options including an `onComplete` callback + // * and `onlyValidate` flag. + // * + // * This function allows you to use custom IDs and optionally set `createdAt`, + // * which is useful for external systems, data migrations, or when the same row + // * may already be created on a different device. + // * + // * Returns a Result type - use `.ok` to check if the upsert succeeded, and + // * `.value.id` to access the ID on success, or `.error` to handle validation + // * errors. + // * + // * Evolu does not use SQL for mutations to ensure data can be safely and + // * predictably merged without conflicts. Explicit mutations also allow Evolu + // * to automatically update {@link SystemColumns}. + // * + // * ### Example + // * + // * ```ts + // * // Use deterministic ID for stable upserts across devices + // * const stableId = createIdFromString("my-todo-1"); + // * + // * const result = evolu.upsert("todo", { + // * id: stableId, + // * title: "Learn Evolu", + // * isCompleted: false, + // * }); + // * + // * if (result.ok) { + // * console.log("Todo upserted with ID:", result.value.id); + // * } else { + // * console.error("Validation error:", result.error); + // * } + // * + // * // Data migration with custom createdAt + // * evolu.upsert("todo", { + // * id: externalId, + // * title: "Migrated todo", + // * createdAt: new Date("2023-01-01"), // Preserve original timestamp + // * }); + // * + // * // With onComplete callback + // * evolu.upsert( + // * "todo", + // * { id: stableId, title: "Updated title" }, + // * { + // * onComplete: () => { + // * console.log("Upsert completed"); + // * }, + // * }, + // * ); + // * ``` // */ - // readonly reloadApp: () => void; + // upsert: Mutation; + + // // /** + // // * Delete {@link AppOwner} and all their data from the current device. After + // // * the deletion, Evolu will purge the application state. For browsers, this + // // * will reload all tabs using Evolu. For native apps, it will restart the + // // * app. + // // * + // // * Reloading can be turned off via options if you want to provide a different + // // * UX. + // // */ + // // readonly resetAppOwner: (options?: { + // // readonly reload?: boolean; + // // }) => Promise; + + // // /** + // // * Restore {@link AppOwner} with all their synced data. It uses + // // * {@link Evolu.resetAppOwner}, so be careful. + // // */ + // // readonly restoreAppOwner: ( + // // mnemonic: Mnemonic, + // // options?: { + // // readonly reload?: boolean; + // // }, + // // ) => Promise; + + // // /** + // // * Reload the app in a platform-specific way. For browsers, this will reload + // // * all tabs using Evolu. For native apps, it will restart the app. + // // */ + // // readonly reloadApp: () => void; - /** - * Export SQLite database file as Uint8Array. - * - * In the future, it will be possible to import a database and export/import - * history for 1:1 migrations across owners. - */ - readonly exportDatabase: () => Promise; + // /** + // * Export SQLite database file as Uint8Array. + // * + // * In the future, it will be possible to import a database and export/import + // * history for 1:1 migrations across owners. + // */ + // readonly exportDatabase: () => Promise>; /** * Use a {@link SyncOwner}. Returns a {@link UnuseOwner}. @@ -533,626 +487,646 @@ export interface Evolu extends Disposable { /** Function returned by {@link Evolu.useOwner} to stop using an {@link SyncOwner}. */ export type UnuseOwner = () => void; -export type EvoluDeps = EvoluPlatformDeps & - ConsoleDep & - DisposableDep & - ErrorStoreDep & - RandomBytesDep; - -export type EvoluPlatformDeps = CreateMessageChannelDep & - ReloadAppDep & - EvoluWorkerDep & - Partial; +export type EvoluDeps = EvoluPlatformDeps; +// ErrorStoreDep & -export interface ErrorStoreDep { - /** - * Shared error store for all Evolu instances. Subscribe once to handle errors - * globally across all instances. - * - * ### Example - * - * ```ts - * deps.evoluError.subscribe(() => { - * const error = deps.evoluError.get(); - * if (!error) return; - * console.error(error); - * }); - * ``` - */ - readonly evoluError: ReadonlyStore; -} +export type EvoluPlatformDeps = ReloadAppDep & Partial; /** Creates Evolu dependencies from platform-specific dependencies. */ +// eslint-disable-next-line arrow-body-style export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => { - const disposableStack = new DisposableStack(); - const evoluError = createErrorStore({ ...deps, disposableStack }); - - return { - ...deps, - ...createDisposableDep(disposableStack), - console: createConsole(), - evoluError, - randomBytes: createRandomBytes(), - }; -}; - -const createErrorStore = ( - deps: CreateMessageChannelDep & EvoluWorkerDep & DisposableStackDep, -): Store => { - const errorChannel = deps.disposableStack.use( - deps.createMessageChannel(), - ); - const evoluError = deps.disposableStack.use( - createStore(null), - ); - - deps.evoluWorker.port.postMessage( - { type: "InitErrorStore", port: errorChannel.port1.native }, - [errorChannel.port1.native], - ); - - errorChannel.port2.onMessage = (error) => { - evoluError.set(error); - }; - - return evoluError; + return deps; + // const disposableStack = new DisposableStack(); + // const evoluError = createErrorStore({ ...deps, disposableStack }); + + // return { + // ...deps, + // ...createDisposableDep(disposableStack), + // console: createConsole(), + // evoluError, + // randomBytes: createRandomBytes(), + // }; }; /** - * Creates an {@link Evolu} instance for a platform configured with the specified - * {@link EvoluSchema} and optional {@link EvoluConfig} providing a typed - * interface for querying, mutating, and syncing your application's data. - * - * ### Example - * - * ```ts - * const TodoId = id("Todo"); - * type TodoId = InferType; - * - * const TodoCategoryId = id("TodoCategory"); - * type TodoCategoryId = InferType; - * - * const NonEmptyString50 = maxLength(50, NonEmptyString); - * type NonEmptyString50 = InferType; - * - * const Schema = { - * todo: { - * id: TodoId, - * title: NonEmptyString1000, - * isCompleted: nullOr(SqliteBoolean), - * categoryId: nullOr(TodoCategoryId), - * }, - * todoCategory: { - * id: TodoCategoryId, - * name: NonEmptyString50, - * }, - * }; - * - * const evolu = createEvolu(evoluReactDeps)(Schema); - * ``` + * Creates an {@link Evolu} instance from {@link EvoluSchema} and + * {@link EvoluConfig}. */ export const createEvolu = - (deps: EvoluDeps) => ( - schema: ValidateSchema extends never ? S : ValidateSchema, - { - name, - // transports define how Evolu connects to owners; default uses the public WebSocket service. - transports: _transports = [ - { type: "WebSocket", url: "wss://free.evoluhq.com" }, - ], - externalAppOwner, - inMemory: _inMemory, - indexes: _indexes, - }: EvoluConfig, - ): Evolu => { - // Cast schema to S since ValidateSchema ensures type safety at compile time. - // At runtime, schema is always valid because invalid schemas are compile errors. - const validSchema = schema as S; - - const errorStore = createStore(null); - const rowsStore = createStore(new Map()); - const subscribedQueries = createSubscribedQueries(rowsStore); - const loadingPromises = createLoadingPromises(subscribedQueries); - const onCompleteCallbacks = createCallbacks(deps); - const exportCallbacks = createCallbacks(deps); - - const loadQueryMicrotaskQueue: Array = []; - const useOwnerMicrotaskQueue: Array<[SyncOwner, boolean, Uint8Array]> = []; - - const { promise: appOwner, resolve: resolveAppOwner } = - Promise.withResolvers(); - if (externalAppOwner) resolveAppOwner(externalAppOwner); - - // deps.sharedWorker. - - // const schema = _schema as EvoluSchema; - - // const { indexes, reloadUrl = "/", ...partialDbConfig } = config ?? {}; - - // const dbConfig: DbConfig = { ...defaultDbConfig, ...partialDbConfig }; - - // deps.console.log("[evolu]", "createEvoluInstance", { - // name: dbConfig.name, - // }); - - // // TODO: Update it for the owner-api - // const _syncStore = createStore(initialSyncState); - - // const dbWorker = deps.createDbWorker(dbConfig.name); - - // const getTabId = () => { - // tabId ??= createId(deps); - // return tabId; - // }; - - // // Worker responses are delivered to all tabs. Each case must handle this - // // properly (e.g., AppOwner promise resolves only once, tabId filtering). - // dbWorker.onMessage((message) => { - // switch (message.type) { - // case "onError": { - // errorStore.set(message.error); - // break; - // } - - // case "onGetAppOwner": { - // resolveAppOwner(message.appOwner); - // break; - // } - - // case "onQueryPatches": { - // if (message.tabId !== getTabId()) return; - - // const state = rowsStore.get(); - // const nextState = new Map([ - // ...state, - // ...message.queryPatches.map( - // ({ query, patches }): [Query, ReadonlyArray] => [ - // query, - // applyPatches(patches, state.get(query) ?? emptyRows), - // ], - // ), - // ]); - - // for (const { query } of message.queryPatches) { - // loadingPromises.resolve(query, nextState.get(query) ?? emptyRows); - // } - - // if (deps.flushSync && message.onCompleteIds.length > 0) { - // deps.flushSync(() => { - // rowsStore.set(nextState); - // }); - // } else { - // rowsStore.set(nextState); - // } - - // for (const id of message.onCompleteIds) { - // onCompleteCallbacks.execute(id); - // } - // break; - // } - - // case "refreshQueries": { - // if (message.tabId && message.tabId === getTabId()) return; - - // const loadingPromisesQueries = loadingPromises.getQueries(); - // loadingPromises.releaseUnsubscribedOnMutation(); - - // const queries = dedupeArray([ - // ...loadingPromisesQueries, - // ...subscribedQueries.get(), - // ]); - - // if (isNonEmptyArray(queries)) { - // dbWorker.postMessage({ type: "query", tabId: getTabId(), queries }); - // } - - // break; - // } - - // case "onReset": { - // if (message.reload) { - // deps.reloadApp(reloadUrl); - // } else { - // onCompleteCallbacks.execute(message.onCompleteId); - // } - // break; - // } - - // case "onExport": { - // exportCallbacks.execute( - // message.onCompleteId, - // message.file as Uint8Array, - // ); - // break; - // } - - // default: - // exhaustiveCheck(message); - // } - // }); - - // const dbSchema = evoluSchemaToDbSchema(schema, indexes); - - const mutationTypesCache = new Map< - MutationKind, - Map>> - >(); - - // Lazy create mutation Types like this: `insertable(Schema.todo)` - const getMutationType = (table: string, kind: MutationKind) => { - let types = mutationTypesCache.get(kind); - if (!types) { - types = new Map(); - mutationTypesCache.set(kind, types); - } - let type = types.get(table); - if (!type) { - type = { insert: insertable, update: updateable, upsert: upsertable }[ - kind - ](validSchema[table]); - types.set(table, type); - } - return type; - }; - - // dbWorker.postMessage({ type: "init", config: dbConfig, dbSchema }); - - // // We can't use `init` to get AppOwner because `init` runs only once per n tabs. - // dbWorker.postMessage({ type: "getAppOwner" }); - - const mutateMicrotaskQueue: Array< - [MutationChange | null, MutationOptions["onComplete"] | undefined] - > = []; - - const createMutation = - (kind: Kind): Mutation => - ( - table: TableName, - props: InferInput>>, - options?: MutationOptions, - ): Result< - { readonly id: S[TableName]["id"]["Type"] }, - InferErrors>> - > => { - const result = getMutationType(table as string, kind).fromUnknown( - props, - ); - - const id = - kind === "insert" - ? createId(deps) - : (props as unknown as { id: Id }).id; - - if (options?.onlyValidate !== true) { - if (!result.ok) { - // Mark the transaction as invalid by pushing null - mutateMicrotaskQueue.push([null, undefined]); - } else { - const { id: _, isDeleted, ...values } = result.value; - - const dbChange = { - table: table as string, - id, - values, - isInsert: kind === "insert" || kind === "upsert", - isDelete: SqliteBoolean.is(isDeleted) - ? sqliteBooleanToBoolean(isDeleted) - : null, - }; - - assert( - DbChange.is(dbChange), - `Invalid DbChange for table '${String(table)}': Please check schema type errors.`, - ); - - mutateMicrotaskQueue.push([ - { ...dbChange, ownerId: options?.ownerId }, - options?.onComplete, - ]); - } - - if (mutateMicrotaskQueue.length === 1) { - queueMicrotask(processMutationQueue); - } - } - - if (result.ok) - return ok({ id } as { readonly id: S[TableName]["id"]["Type"] }); - - return err( - result.error as InferErrors< - ObjectType> - >, - ); - }; - - const processMutationQueue = () => { - const changes: Array = []; - const onCompletes = []; - - for (const [change, onComplete] of mutateMicrotaskQueue) { - if (change !== null) changes.push(change); - if (onComplete) onCompletes.push(onComplete); - } - - const queueLength = mutateMicrotaskQueue.length; - mutateMicrotaskQueue.length = 0; - - // Don't process any mutations if there was a validation error. - // All mutations within a queue run as a single transaction. - if (changes.length !== queueLength) { - return; - } - - const _onCompleteIds = onCompletes.map(onCompleteCallbacks.register); - loadingPromises.releaseUnsubscribedOnMutation(); - - if (!isNonEmptyArray(changes)) return; - - // TODO: - // dbWorker.postMessage({ - // type: "mutate", - // tabId: getTabId(), - // changes, - // onCompleteIds, - // subscribedQueries: subscribedQueries.get(), - // }); - }; - - const evolu: Evolu = { - name, - - subscribeError: errorStore.subscribe, - getError: errorStore.get, - - loadQuery: (query: Query): Promise> => { - const { promise, isNew } = loadingPromises.get(query); - - if (isNew) { - loadQueryMicrotaskQueue.push(query); - if (loadQueryMicrotaskQueue.length === 1) { - queueMicrotask(() => { - const queries = dedupeArray(loadQueryMicrotaskQueue); - loadQueryMicrotaskQueue.length = 0; - assertNonEmptyReadonlyArray(queries); - deps.console.log("[evolu]", "loadQuery", { queries }); - // dbWorker.postMessage({ - // type: "query", - // tabId: getTabId(), - // queries, - // }); - }); - } - } - - return promise; - }, - - loadQueries: >( - queries: [...Q], - ): [...QueriesToQueryRowsPromises] => - queries.map(evolu.loadQuery) as [...QueriesToQueryRowsPromises], - - subscribeQuery: (query) => (listener) => { - // Call the listener only if the result has been changed. - let previousRows: unknown = null; - const unsubscribe = subscribedQueries.subscribe(query)(() => { - const rows = evolu.getQueryRows(query); - if (previousRows === rows) return; - previousRows = rows; - listener(); - }); - return () => { - previousRows = null; - unsubscribe(); - }; - }, - - getQueryRows: (query: Query): QueryRows => - (rowsStore.get().get(query) ?? emptyRows) as QueryRows, - - appOwner, - - // TODO: Update it for the owner-api - // subscribeSyncState: syncStore.subscribe, - // getSyncState: syncStore.get, - - insert: createMutation("insert"), - update: createMutation("update"), - upsert: createMutation("upsert"), - - // resetAppOwner: (_options) => { - // const { promise, resolve } = Promise.withResolvers(); - // const _onCompleteId = onCompleteCallbacks.register(resolve); - // // dbWorker.postMessage({ - // // type: "reset", - // // onCompleteId, - // // reload: options?.reload ?? true, - // // }); - // return promise; - // }, - - // restoreAppOwner: (_mnemonic, _options) => { - // const { promise, resolve } = Promise.withResolvers(); - // const _onCompleteId = onCompleteCallbacks.register(resolve); - // // dbWorker.postMessage({ - // // type: "reset", - // // onCompleteId, - // // reload: options?.reload ?? true, - // // restore: { mnemonic, dbSchema }, - // // }); - // return promise; - // }, - - // reloadApp: () => { - // // TODO: - // // deps.reloadApp(reloadUrl); - // }, - - // ensureSchema: (schema) => { - // mutationTypesCache.clear(); - // const dbSchema = evoluSchemaToDbSchema(schema); - // dbWorker.postMessage({ type: "ensureDbSchema", dbSchema }); - // }, - - exportDatabase: () => { - const { promise, resolve } = Promise.withResolvers(); - const _onCompleteId = exportCallbacks.register(resolve); - // dbWorker.postMessage({ type: "export", onCompleteId }); - return promise; - }, - - useOwner: (owner) => { - const scheduleOwnerQueueProcessing = () => { - if (useOwnerMicrotaskQueue.length !== 1) return; - queueMicrotask(() => { - const queue = [...useOwnerMicrotaskQueue]; - useOwnerMicrotaskQueue.length = 0; - - const result: Array<[SyncOwner, boolean, Uint8Array]> = []; - const skipIndices = new Set(); - - for (let i = 0; i < queue.length; i++) { - if (skipIndices.has(i)) continue; - - const [currentOwner, currentUse, currentOwnerSerialized] = - queue[i]; - - // Look for opposite action with same owner - for (let j = i + 1; j < queue.length; j++) { - if (skipIndices.has(j)) continue; - - const [, otherUse, otherOwnerSerialized] = queue[j]; - - if ( - currentUse !== otherUse && - eqArrayNumber(currentOwnerSerialized, otherOwnerSerialized) - ) { - // Found cancel-out pair, skip both - skipIndices.add(i).add(j); - break; - } - } - - if (!skipIndices.has(i)) { - result.push([currentOwner, currentUse, currentOwnerSerialized]); - } - } - - for (const [_owner, _use] of result) { - // dbWorker.postMessage({ type: "useOwner", owner, use }); - } - }); - }; - - useOwnerMicrotaskQueue.push([owner, true, pack(owner)]); - scheduleOwnerQueueProcessing(); - - const unuse = () => { - useOwnerMicrotaskQueue.push([owner, false, pack(owner)]); - scheduleOwnerQueueProcessing(); - }; - - return unuse; - }, - - /** Disposal is not implemented yet. */ - [Symbol.dispose]: () => { - throw new Error("Evolu instance disposal is not yet implemented"); - }, - }; - - return evolu; - }; - -interface LoadingPromises { - get: ( - query: Query, - ) => { - readonly promise: Promise>; - readonly isNew: boolean; + _schema: ValidateSchema extends never ? S : ValidateSchema, + config: EvoluConfig, + ): Task, never, EvoluPlatformDeps> => + (run) => { + const appOwner = + config.appOwner ?? + config.externalAppOwner ?? + createAppOwner(createOwnerSecret(run.deps)); + + return ok({ appOwner } as Evolu); }; - resolve: (query: Query, rows: ReadonlyArray) => void; - - releaseUnsubscribedOnMutation: () => void; - - getQueries: () => ReadonlyArray; -} - -interface LoadingPromise { - /** Promise with props for the React use hook. */ - promise: Promise & { - status?: "pending" | "fulfilled" | "rejected"; - value?: QueryRows; - reason?: unknown; - }; - resolve: (rows: QueryRows) => void; - releaseOnResolve: boolean; -} - -const createLoadingPromises = ( - subscribedQueries: SubscribedQueries, -): LoadingPromises => { - const loadingPromiseMap = new Map(); - - return { - get: ( - query: Query, - ): { - readonly promise: Promise>; - readonly isNew: boolean; - } => { - let loadingPromise = loadingPromiseMap.get(query); - const isNew = !loadingPromise; - if (!loadingPromise) { - const { promise, resolve } = Promise.withResolvers(); - loadingPromise = { resolve, promise, releaseOnResolve: false }; - loadingPromiseMap.set(query, loadingPromise); - } - return { - promise: loadingPromise.promise as Promise>, - isNew, - }; - }, - - resolve: (query, rows) => { - const loadingPromise = loadingPromiseMap.get(query); - if (!loadingPromise) return; - - if (loadingPromise.promise.status !== "fulfilled") { - loadingPromise.resolve(rows); - } else { - loadingPromise.promise = Promise.resolve(rows); - } - - // Set status and value fields for React's `use` Hook to unwrap synchronously. - // While undocumented in React docs, React still uses these properties internally, - // and Evolu's own promise caching logic depends on checking `promise.status`. - // https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md - void Object.assign(loadingPromise.promise, { - status: "fulfilled", - value: rows, - }); - - if (loadingPromise.releaseOnResolve) { - loadingPromiseMap.delete(query); - } - }, - - releaseUnsubscribedOnMutation: () => { - [...loadingPromiseMap.entries()] - .filter(([query]) => !subscribedQueries.has(query)) - .forEach(([query, loadingPromise]) => { - if (loadingPromise.promise.status === "fulfilled") { - loadingPromiseMap.delete(query); - } else { - loadingPromise.releaseOnResolve = true; - } - }); - }, - - getQueries: () => Array.from(loadingPromiseMap.keys()), - }; -}; +// CreateMessageChannelDep & +// ReloadAppDep & +// EvoluWorkerDep & +// Partial; + +// export interface ErrorStoreDep { +// /** +// * Shared error store for all Evolu instances. Subscribe once to handle errors +// * globally across all instances. +// * +// * ### Example +// * +// * ```ts +// * deps.evoluError.subscribe(() => { +// * const error = deps.evoluError.get(); +// * if (!error) return; +// * console.error(error); +// * }); +// * ``` +// */ +// readonly evoluError: ReadonlyStore; +// } + +// const createErrorStore = ( +// deps: CreateMessageChannelDep & EvoluWorkerDep & DisposableStackDep, +// ): Store => { +// const errorChannel = deps.disposableStack.use( +// deps.createMessageChannel(), +// ); +// const evoluError = deps.disposableStack.use( +// createStore(null), +// ); + +// deps.evoluWorker.port.postMessage( +// { type: "InitErrorStore", port: errorChannel.port1.native }, +// [errorChannel.port1.native], +// ); + +// errorChannel.port2.onMessage = (error) => { +// evoluError.set(error); +// }; + +// return evoluError; +// }; + +// /** +// * Creates an {@link Evolu} instance for a platform configured with the specified +// * {@link EvoluSchema} and optional {@link EvoluConfig} providing a typed +// * interface for querying, mutating, and syncing data. +// * +// * ### Example +// * +// * ```ts +// * const TodoId = id("Todo"); +// * type TodoId = InferType; +// * +// * const TodoCategoryId = id("TodoCategory"); +// * type TodoCategoryId = InferType; +// * +// * const NonEmptyString50 = maxLength(50, NonEmptyString); +// * type NonEmptyString50 = InferType; +// * +// * const Schema = { +// * todo: { +// * id: TodoId, +// * title: NonEmptyString1000, +// * isCompleted: nullOr(SqliteBoolean), +// * categoryId: nullOr(TodoCategoryId), +// * }, +// * todoCategory: { +// * id: TodoCategoryId, +// * name: NonEmptyString50, +// * }, +// * }; +// * +// * const evolu = createEvolu(evoluReactDeps)(Schema); +// * ``` +// */ +// export const createEvolu = +// (deps: EvoluDeps) => +// ( +// schema: ValidateSchema extends never ? S : ValidateSchema, +// { +// name, +// // TODO: +// transports: _transports = [ +// { type: "WebSocket", url: "wss://free.evoluhq.com" }, +// ], +// externalAppOwner, +// inMemory: _inMemory, +// indexes: _indexes, +// }: EvoluConfig, +// ): Evolu => { +// // Cast schema to S since ValidateSchema ensures type safety at compile time. +// // At runtime, schema is always valid because invalid schemas are compile errors. +// const validSchema = schema as S; + +// const errorStore = createStore(null); +// const rowsStore = createStore(new Map()); +// const subscribedQueries = createSubscribedQueries(rowsStore); +// const loadingPromises = createLoadingPromises(subscribedQueries); +// const onCompleteCallbacks = createCallbacks(deps); +// const exportCallbacks = createCallbacks>(deps); + +// const loadQueryMicrotaskQueue: Array = []; +// const useOwnerMicrotaskQueue: Array<[SyncOwner, boolean, Uint8Array]> = []; + +// const { promise: appOwner, resolve: resolveAppOwner } = +// Promise.withResolvers(); +// if (externalAppOwner) resolveAppOwner(externalAppOwner); + +// // deps.sharedWorker. + +// // const schema = _schema as EvoluSchema; + +// // const { indexes, reloadUrl = "/", ...partialDbConfig } = config ?? {}; + +// // const dbConfig: DbConfig = { ...defaultDbConfig, ...partialDbConfig }; + +// // deps.console.log("[evolu]", "createEvoluInstance", { +// // name: dbConfig.name, +// // }); + +// // // TODO: Update it for the owner-api +// // const _syncStore = createStore(initialSyncState); + +// // const dbWorker = deps.createDbWorker(dbConfig.name); + +// // const getTabId = () => { +// // tabId ??= createId(deps); +// // return tabId; +// // }; + +// // // Worker responses are delivered to all tabs. Each case must handle this +// // // properly (e.g., AppOwner promise resolves only once, tabId filtering). +// // dbWorker.onMessage((message) => { +// // switch (message.type) { +// // case "onError": { +// // errorStore.set(message.error); +// // break; +// // } + +// // case "onGetAppOwner": { +// // resolveAppOwner(message.appOwner); +// // break; +// // } + +// // case "onQueryPatches": { +// // if (message.tabId !== getTabId()) return; + +// // const state = rowsStore.get(); +// // const nextState = new Map([ +// // ...state, +// // ...message.queryPatches.map( +// // ({ query, patches }): [Query, ReadonlyArray] => [ +// // query, +// // applyPatches(patches, state.get(query) ?? emptyRows), +// // ], +// // ), +// // ]); + +// // for (const { query } of message.queryPatches) { +// // loadingPromises.resolve(query, nextState.get(query) ?? emptyRows); +// // } + +// // if (deps.flushSync && message.onCompleteIds.length > 0) { +// // deps.flushSync(() => { +// // rowsStore.set(nextState); +// // }); +// // } else { +// // rowsStore.set(nextState); +// // } + +// // for (const id of message.onCompleteIds) { +// // onCompleteCallbacks.execute(id); +// // } +// // break; +// // } + +// // case "refreshQueries": { +// // if (message.tabId && message.tabId === getTabId()) return; + +// // const loadingPromisesQueries = loadingPromises.getQueries(); +// // loadingPromises.releaseUnsubscribedOnMutation(); + +// // const queries = dedupeArray([ +// // ...loadingPromisesQueries, +// // ...subscribedQueries.get(), +// // ]); + +// // if (isNonEmptyArray(queries)) { +// // dbWorker.postMessage({ type: "query", tabId: getTabId(), queries }); +// // } + +// // break; +// // } + +// // case "onReset": { +// // if (message.reload) { +// // deps.reloadApp(reloadUrl); +// // } else { +// // onCompleteCallbacks.execute(message.onCompleteId); +// // } +// // break; +// // } + +// // case "onExport": { +// // exportCallbacks.execute( +// // message.onCompleteId, +// // message.file as Uint8Array, +// // ); +// // break; +// // } + +// // default: +// // exhaustiveCheck(message); +// // } +// // }); + +// // const dbSchema = evoluSchemaToDbSchema(schema, indexes); + +// const mutationTypesCache = new Map< +// MutationKind, +// Map>> +// >(); + +// // Lazy create mutation Types like this: `insertable(Schema.todo)` +// const getMutationType = (table: string, kind: MutationKind) => { +// let types = mutationTypesCache.get(kind); +// if (!types) { +// types = new Map(); +// mutationTypesCache.set(kind, types); +// } +// let type = types.get(table); +// if (!type) { +// type = { insert: insertable, update: updateable, upsert: upsertable }[ +// kind +// ](validSchema[table]); +// types.set(table, type); +// } +// return type; +// }; + +// // dbWorker.postMessage({ type: "init", config: dbConfig, dbSchema }); + +// // // We can't use `init` to get AppOwner because `init` runs only once per n tabs. +// // dbWorker.postMessage({ type: "getAppOwner" }); + +// const mutateMicrotaskQueue: Array< +// [MutationChange | null, MutationOptions["onComplete"] | undefined] +// > = []; + +// const createMutation = +// (kind: Kind): Mutation => +// ( +// table: TableName, +// props: InferInput>>, +// options?: MutationOptions, +// ): Result< +// { readonly id: S[TableName]["id"]["Type"] }, +// InferErrors>> +// > => { +// const result = getMutationType(table as string, kind).fromUnknown( +// props, +// ); + +// const id = +// kind === "insert" +// ? createId(deps) +// : (props as unknown as { id: Id }).id; + +// if (options?.onlyValidate !== true) { +// if (!result.ok) { +// // Mark the transaction as invalid by pushing null +// mutateMicrotaskQueue.push([null, undefined]); +// } else { +// const { id: _, isDeleted, ...values } = result.value; + +// const dbChange = { +// table: table as string, +// id, +// values, +// isInsert: kind === "insert" || kind === "upsert", +// isDelete: SqliteBoolean.is(isDeleted) +// ? sqliteBooleanToBoolean(isDeleted) +// : null, +// }; + +// assert( +// DbChange.is(dbChange), +// `Invalid DbChange for table '${String(table)}': Please check schema type errors.`, +// ); + +// mutateMicrotaskQueue.push([ +// { ...dbChange, ownerId: options?.ownerId }, +// options?.onComplete, +// ]); +// } + +// if (mutateMicrotaskQueue.length === 1) { +// queueMicrotask(processMutationQueue); +// } +// } + +// if (result.ok) +// return ok({ id } as { readonly id: S[TableName]["id"]["Type"] }); + +// return err( +// result.error as InferErrors< +// ObjectType> +// >, +// ); +// }; + +// const processMutationQueue = () => { +// const changes: Array = []; +// const onCompletes = []; + +// for (const [change, onComplete] of mutateMicrotaskQueue) { +// if (change !== null) changes.push(change); +// if (onComplete) onCompletes.push(onComplete); +// } + +// const queueLength = mutateMicrotaskQueue.length; +// mutateMicrotaskQueue.length = 0; + +// // Don't process any mutations if there was a validation error. +// // All mutations within a queue run as a single transaction. +// if (changes.length !== queueLength) { +// return; +// } + +// const _onCompleteIds = onCompletes.map(onCompleteCallbacks.register); +// loadingPromises.releaseUnsubscribedOnMutation(); + +// if (!isNonEmptyArray(changes)) return; + +// // TODO: +// // dbWorker.postMessage({ +// // type: "mutate", +// // tabId: getTabId(), +// // changes, +// // onCompleteIds, +// // subscribedQueries: subscribedQueries.get(), +// // }); +// }; + +// const evolu: Evolu = { +// name, + +// subscribeError: errorStore.subscribe, +// getError: errorStore.get, + +// loadQuery: (query: Query): Promise> => { +// const { promise, isNew } = loadingPromises.get(query); + +// if (isNew) { +// loadQueryMicrotaskQueue.push(query); +// if (loadQueryMicrotaskQueue.length === 1) { +// queueMicrotask(() => { +// const queries = dedupeArray(loadQueryMicrotaskQueue); +// loadQueryMicrotaskQueue.length = 0; +// assertNonEmptyReadonlyArray(queries); +// deps.console.log("[evolu]", "loadQuery", { queries }); +// // dbWorker.postMessage({ +// // type: "query", +// // tabId: getTabId(), +// // queries, +// // }); +// }); +// } +// } + +// return promise; +// }, + +// loadQueries: >( +// queries: [...Q], +// ): [...QueriesToQueryRowsPromises] => +// queries.map(evolu.loadQuery) as [...QueriesToQueryRowsPromises], + +// subscribeQuery: (query) => (listener) => { +// // Call the listener only if the result has been changed. +// let previousRows: unknown = null; +// const unsubscribe = subscribedQueries.subscribe(query)(() => { +// const rows = evolu.getQueryRows(query); +// if (previousRows === rows) return; +// previousRows = rows; +// listener(); +// }); +// return () => { +// previousRows = null; +// unsubscribe(); +// }; +// }, + +// getQueryRows: (query: Query): QueryRows => +// (rowsStore.get().get(query) ?? emptyRows) as QueryRows, + +// appOwner, + +// // TODO: Update it for the owner-api +// // subscribeSyncState: syncStore.subscribe, +// // getSyncState: syncStore.get, + +// insert: createMutation("insert"), +// update: createMutation("update"), +// upsert: createMutation("upsert"), + +// // resetAppOwner: (_options) => { +// // const { promise, resolve } = Promise.withResolvers(); +// // const _onCompleteId = onCompleteCallbacks.register(resolve); +// // // dbWorker.postMessage({ +// // // type: "reset", +// // // onCompleteId, +// // // reload: options?.reload ?? true, +// // // }); +// // return promise; +// // }, + +// // restoreAppOwner: (_mnemonic, _options) => { +// // const { promise, resolve } = Promise.withResolvers(); +// // const _onCompleteId = onCompleteCallbacks.register(resolve); +// // // dbWorker.postMessage({ +// // // type: "reset", +// // // onCompleteId, +// // // reload: options?.reload ?? true, +// // // restore: { mnemonic, dbSchema }, +// // // }); +// // return promise; +// // }, + +// // reloadApp: () => { +// // // TODO: +// // // deps.reloadApp(reloadUrl); +// // }, + +// // ensureSchema: (schema) => { +// // mutationTypesCache.clear(); +// // const dbSchema = evoluSchemaToDbSchema(schema); +// // dbWorker.postMessage({ type: "ensureDbSchema", dbSchema }); +// // }, + +// exportDatabase: () => { +// const { promise, resolve } = +// Promise.withResolvers>(); +// const _onCompleteId = exportCallbacks.register(resolve); +// // dbWorker.postMessage({ type: "export", onCompleteId }); +// return promise; +// }, + +// useOwner: (owner) => { +// const scheduleOwnerQueueProcessing = () => { +// if (useOwnerMicrotaskQueue.length !== 1) return; +// queueMicrotask(() => { +// const queue = [...useOwnerMicrotaskQueue]; +// useOwnerMicrotaskQueue.length = 0; + +// const result: Array<[SyncOwner, boolean, Uint8Array]> = []; +// const skipIndices = new Set(); + +// for (let i = 0; i < queue.length; i++) { +// if (skipIndices.has(i)) continue; + +// const [currentOwner, currentUse, currentOwnerSerialized] = +// queue[i]; + +// // Look for opposite action with same owner +// for (let j = i + 1; j < queue.length; j++) { +// if (skipIndices.has(j)) continue; + +// const [, otherUse, otherOwnerSerialized] = queue[j]; + +// if ( +// currentUse !== otherUse && +// eqArrayNumber(currentOwnerSerialized, otherOwnerSerialized) +// ) { +// // Found cancel-out pair, skip both +// skipIndices.add(i).add(j); +// break; +// } +// } + +// if (!skipIndices.has(i)) { +// result.push([currentOwner, currentUse, currentOwnerSerialized]); +// } +// } + +// for (const [_owner, _use] of result) { +// // dbWorker.postMessage({ type: "useOwner", owner, use }); +// } +// }); +// }; + +// useOwnerMicrotaskQueue.push([owner, true, pack(owner)]); +// scheduleOwnerQueueProcessing(); + +// const unuse = () => { +// useOwnerMicrotaskQueue.push([owner, false, pack(owner)]); +// scheduleOwnerQueueProcessing(); +// }; + +// return unuse; +// }, + +// /** Disposal is not implemented yet. */ +// [Symbol.dispose]: () => { +// throw new Error("Evolu instance disposal is not yet implemented"); +// }, +// }; + +// return evolu; +// }; + +// interface LoadingPromises { +// get: ( +// query: Query, +// ) => { +// readonly promise: Promise>; +// readonly isNew: boolean; +// }; + +// resolve: (query: Query, rows: ReadonlyArray) => void; + +// releaseUnsubscribedOnMutation: () => void; + +// getQueries: () => ReadonlyArray; +// } + +// interface LoadingPromise { +// /** Promise with props for the React use hook. */ +// promise: Promise & { +// status?: "pending" | "fulfilled" | "rejected"; +// value?: QueryRows; +// reason?: unknown; +// }; +// resolve: (rows: QueryRows) => void; +// releaseOnResolve: boolean; +// } + +// const createLoadingPromises = ( +// subscribedQueries: SubscribedQueries, +// ): LoadingPromises => { +// const loadingPromiseMap = new Map(); + +// return { +// get: ( +// query: Query, +// ): { +// readonly promise: Promise>; +// readonly isNew: boolean; +// } => { +// let loadingPromise = loadingPromiseMap.get(query); +// const isNew = !loadingPromise; +// if (!loadingPromise) { +// const { promise, resolve } = Promise.withResolvers(); +// loadingPromise = { resolve, promise, releaseOnResolve: false }; +// loadingPromiseMap.set(query, loadingPromise); +// } +// return { +// promise: loadingPromise.promise as Promise>, +// isNew, +// }; +// }, + +// resolve: (query, rows) => { +// const loadingPromise = loadingPromiseMap.get(query); +// if (!loadingPromise) return; + +// if (loadingPromise.promise.status !== "fulfilled") { +// loadingPromise.resolve(rows); +// } else { +// loadingPromise.promise = Promise.resolve(rows); +// } + +// // Set status and value fields for React's `use` Hook to unwrap synchronously. +// // While undocumented in React docs, React still uses these properties internally, +// // and Evolu's own promise caching logic depends on checking `promise.status`. +// // https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md +// void Object.assign(loadingPromise.promise, { +// status: "fulfilled", +// value: rows, +// }); + +// if (loadingPromise.releaseOnResolve) { +// loadingPromiseMap.delete(query); +// } +// }, + +// releaseUnsubscribedOnMutation: () => { +// [...loadingPromiseMap.entries()] +// .filter(([query]) => !subscribedQueries.has(query)) +// .forEach(([query, loadingPromise]) => { +// if (loadingPromise.promise.status === "fulfilled") { +// loadingPromiseMap.delete(query); +// } else { +// loadingPromise.releaseOnResolve = true; +// } +// }); +// }, + +// getQueries: () => Array.from(loadingPromiseMap.keys()), +// }; +// }; diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index 4920a5f90..d561d6bc9 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -1,7 +1,56 @@ -import { expect, test } from "vitest"; +import { describe, expect, test } from "vitest"; +import { lazyVoid } from "../../src/Function.js"; +import { createEvolu, createEvoluDeps } from "../../src/local-first/Evolu.js"; +import { SqliteBoolean } from "../../src/Sqlite.js"; +import { testCreateRun } from "../../src/Test.js"; +import { id, NonEmptyString100, nullOr } from "../../src/Type.js"; +import { testSimpleName } from "../_deps.js"; +import { testAppOwner } from "./_fixtures.js"; + +const TodoId = id("Todo"); +type TodoId = typeof TodoId.Type; + +const Schema = { + todo: { + id: TodoId, + title: NonEmptyString100, + isCompleted: nullOr(SqliteBoolean), + }, +}; + +const createEvoluRun = () => testCreateRun({ reloadApp: lazyVoid }); + +test("createEvoluDeps returns deps unchanged", () => { + const deps = { reloadApp: lazyVoid }; + expect(createEvoluDeps(deps)).toBe(deps); +}); + +describe("createEvolu", () => { + test("appOwner from config is exposed as evolu.appOwner", async () => { + await using run = createEvoluRun(); + + const result = await run( + createEvolu(Schema, { name: testSimpleName, appOwner: testAppOwner }), + ); + + expect(result.ok && result.value.appOwner).toBe(testAppOwner); + }); + + test("appOwner is created when omitted from config", async () => { + await using run = createEvoluRun(); + + const result = await run(createEvolu(Schema, { name: testSimpleName })); -test("TODO", () => { - expect(1).toBe(1); + expect(result.ok && result.value.appOwner).toMatchInlineSnapshot(` + { + "encryptionKey": uint8:[50,42,177,193,76,197,92,240,100,30,92,209,205,42,108,45,195,37,118,158,238,206,161,144,11,241,190,167,14,254,186,53], + "id": "t_xEbmXuICrgDm3Ob0_afw", + "mnemonic": "old jungle over boy ankle suggest service source civil insane end silver polar swap flight diagram keep fix gauge social wink subway bronze leader", + "type": "AppOwner", + "writeKey": uint8:[129,228,239,103,127,237,0,59,174,241,77,12,26,180,213,14], + } + `); + }); }); // import { describe, expectTypeOf, test } from "vitest"; From 601e91379ceb87c870f5e4698aa976451ff23dbf Mon Sep 17 00:00:00 2001 From: Miccy Date: Sat, 7 Feb 2026 20:55:20 +0100 Subject: [PATCH 16/37] fix(sync): add compatibility shims for task-based Evolu transition --- packages/common/src/Test.ts | 7 ++++++ packages/common/src/local-first/Evolu.ts | 28 +++++++++++++++++++----- packages/svelte/src/lib/index.svelte.ts | 6 ++--- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/common/src/Test.ts b/packages/common/src/Test.ts index abffa0534..905f5949d 100644 --- a/packages/common/src/Test.ts +++ b/packages/common/src/Test.ts @@ -88,3 +88,10 @@ export function testCreateRunner(deps?: D): Runner { const defaults = testCreateDeps(); return createRunner({ ...defaults, ...deps } as TestDeps & D); } + +/** + * Backward-compatible alias for upstream naming. + * + * Prefer {@link testCreateRunner} in SQLoot code. + */ +export const testCreateRun: typeof testCreateRunner = testCreateRunner; diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 833985eee..55fe7c950 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -5,10 +5,11 @@ */ import type { Listener, Unsubscribe } from "../Listeners.js"; +import type { FlushSyncDep, ReloadAppDep } from "../Platform.js"; import { ok } from "../Result.js"; import type { Task } from "../Task.js"; -import type { FlushSyncDep, ReloadAppDep } from "../Platform.js"; -import { SimpleName } from "../Type.js"; +import type { SimpleName } from "../Type.js"; +import type { EvoluError } from "./Error.js"; import type { AppOwner, OwnerTransport } from "./Owner.js"; import { createAppOwner, @@ -78,7 +79,7 @@ export interface EvoluConfig { * // Default transports for sync * }); * ``` - */ + */ readonly appOwner?: AppOwner; /** @@ -188,7 +189,7 @@ export interface EvoluConfig { /** Local-first SQL database with typed queries, mutations, and sync. */ export interface Evolu< // eslint-disable-next-line @typescript-eslint/no-unused-vars - S extends EvoluSchema = EvoluSchema, + _S extends EvoluSchema = EvoluSchema, > extends AsyncDisposable { /** The name of the Evolu instance from {@link EvoluConfig}. */ readonly name: SimpleName; @@ -196,6 +197,17 @@ export interface Evolu< /** {@link AppOwner}. */ readonly appOwner: AppOwner; + /** + * Transitional compatibility API. Will be removed once downstream packages + * migrate to Task-native error handling. + */ + readonly subscribeError: (listener: Listener) => Unsubscribe; + + /** + * Transitional compatibility API. Returns `null` in Task-based stub mode. + */ + readonly getError: () => EvoluError | null; + /** * Load {@link Query} and return a promise with {@link QueryRows}. * @@ -494,7 +506,7 @@ export type EvoluPlatformDeps = ReloadAppDep & Partial; /** Creates Evolu dependencies from platform-specific dependencies. */ // eslint-disable-next-line arrow-body-style -export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => { +export const createEvoluDeps = (deps: D): D => { return deps; // const disposableStack = new DisposableStack(); // const evoluError = createErrorStore({ ...deps, disposableStack }); @@ -523,7 +535,11 @@ export const createEvolu = config.externalAppOwner ?? createAppOwner(createOwnerSecret(run.deps)); - return ok({ appOwner } as Evolu); + return ok({ + appOwner, + getError: () => null, + subscribeError: () => () => undefined, + } as unknown as Evolu); }; // CreateMessageChannelDep & diff --git a/packages/svelte/src/lib/index.svelte.ts b/packages/svelte/src/lib/index.svelte.ts index d4b1d6eb7..3b98606a3 100644 --- a/packages/svelte/src/lib/index.svelte.ts +++ b/packages/svelte/src/lib/index.svelte.ts @@ -121,7 +121,7 @@ export const queryState = < }; /** - * Get the {@link AppOwner} promise that resolves when available. + * Get the {@link AppOwner} state when available. * * ### Example * @@ -131,7 +131,7 @@ export const queryState = < * const owner = appOwnerState(evolu); * * // use owner.current in your Svelte templates - * // it will be undefined initially and set once the promise resolves + * // it will be undefined initially and set once available * ``` */ export const appOwnerState = ( @@ -144,7 +144,7 @@ export const appOwnerState = ( let writableState = $state(undefined); $effect(() => { - void evolu.appOwner.then((appOwner) => { + void Promise.resolve(evolu.appOwner).then((appOwner) => { writableState = appOwner; }); return undefined; From c2a1b938b69d88ee38c157e6faf0206b6e058dcd Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 11:55:48 +0100 Subject: [PATCH 17/37] Simplified platform Evolu deps for web, React Native, and React Web Simplified createEvoluDeps across platforms to pass through ReloadAppDep directly. Commented out SharedWorker, MessageChannel, and localAuth code pending redesign. Added createEvoluDeps to React Web with flushSync. Updated React Native shared module and Expo entry points (cherry picked from commit 26bc572c2404c211b77a05229d84b97a233762a1) --- packages/react-native/src/createExpoDeps.ts | 324 +++++++++--------- .../src/exports/bare-op-sqlite.ts | 8 +- .../src/exports/expo-op-sqlite.ts | 30 +- .../react-native/src/exports/expo-sqlite.ts | 24 +- packages/react-native/src/shared.ts | 58 ++-- packages/react-web/src/local-first/Evolu.ts | 9 + packages/web/src/local-first/Evolu.ts | 41 +-- 7 files changed, 262 insertions(+), 232 deletions(-) create mode 100644 packages/react-web/src/local-first/Evolu.ts diff --git a/packages/react-native/src/createExpoDeps.ts b/packages/react-native/src/createExpoDeps.ts index 7f741af7b..bf74ca87d 100644 --- a/packages/react-native/src/createExpoDeps.ts +++ b/packages/react-native/src/createExpoDeps.ts @@ -1,164 +1,164 @@ -import type { - AccessControl, - LocalAuthOptions, - SecureStorage, - SensitiveInfoItem, - StorageMetadata, -} from "@evolu/common"; -import { - type CreateSqliteDriverDep, - type LocalAuth, - localAuthDefaultOptions, - type ReloadApp, -} from "@evolu/common"; -import type { EvoluDeps } from "@evolu/common/local-first"; -import * as Expo from "expo"; -import * as SecureStore from "expo-secure-store"; -import KVStore from "expo-sqlite/kv-store"; -import { createSharedEvoluDeps, createSharedLocalAuth } from "./shared.js"; +// import type { +// AccessControl, +// LocalAuthOptions, +// SecureStorage, +// SensitiveInfoItem, +// StorageMetadata, +// } from "@evolu/common"; +// import { +// type CreateSqliteDriverDep, +// type LocalAuth, +// localAuthDefaultOptions, +// type ReloadApp, +// } from "@evolu/common"; +// import type { EvoluDeps } from "@evolu/common/local-first"; +// import * as Expo from "expo"; +// import * as SecureStore from "expo-secure-store"; +// import KVStore from "expo-sqlite/kv-store"; +// import { createSharedEvoluDeps, createSharedLocalAuth } from "./shared.js"; -const reloadApp: ReloadApp = () => { - void Expo.reloadAppAsync(); -}; +// const reloadApp: ReloadApp = () => { +// void Expo.reloadAppAsync(); +// }; +// +// const createSecureStore = (): SecureStorage => { +// const store: SecureStorage = { +// setItem: async (key, value, options) => { +// const rnsiOpts = convertOptions(options); +// const service = options?.service ?? "default"; +// const metadata = createMetadata(options?.accessControl === "none"); +// await KVStore.setItem(`${service}-${key}`, "1"); +// await SecureStore.setItemAsync( +// key, +// JSON.stringify({ value, metadata }), +// rnsiOpts, +// ); +// return { metadata }; +// }, +// +// getItem: async (key, options) => { +// const rnsiOpts = convertOptions(options); +// const service = options?.service ?? "default"; +// let data: { value: string; metadata: StorageMetadata }; +// try { +// const result = await SecureStore.getItemAsync(key, rnsiOpts); +// if (!result) return null; +// data = JSON.parse(result) as { +// value: string; +// metadata: StorageMetadata; +// }; +// } catch (_error) { +// return null; +// } +// return { key, service, ...data }; +// }, +// +// deleteItem: async (key, options) => { +// const rnsiOpts = convertOptions(options); +// const service = options?.service ?? "default"; +// await Promise.all([ +// KVStore.removeItemAsync(`${service}-${key}`), +// SecureStore.deleteItemAsync(key, rnsiOpts), +// ]); +// return true; +// }, +// +// getAllItems: async (options) => { +// const keys = await KVStore.getAllKeysAsync(); +// const service = options?.service ?? "default"; +// const metadata = createMetadata(options?.accessControl === "none"); +// return keys +// .filter((key) => key.startsWith(`${service}-`)) +// .map((key) => ({ +// key: key.slice(service.length + 1), +// service, +// metadata, +// })); +// }, +// +// clearService: async (options) => { +// const rnsiOpts = convertOptions(options); +// const service = options?.service ?? "default"; +// const items = await store.getAllItems(options); +// await KVStore.multiRemove(items.map((item) => `${service}-${item.key}`)); +// await Promise.all( +// items.map(async (item) => { +// await SecureStore.deleteItemAsync(item.key, rnsiOpts); +// }), +// ); +// }, +// }; +// +// return store; +// }; -const createSecureStore = (): SecureStorage => { - const store: SecureStorage = { - setItem: async (key, value, options) => { - const rnsiOpts = convertOptions(options); - const service = options?.service ?? "default"; - const metadata = createMetadata(options?.accessControl === "none"); - await KVStore.setItem(`${service}-${key}`, "1"); - await SecureStore.setItemAsync( - key, - JSON.stringify({ value, metadata }), - rnsiOpts, - ); - return { metadata }; - }, - - getItem: async (key, options) => { - const rnsiOpts = convertOptions(options); - const service = options?.service ?? "default"; - let data: { value: string; metadata: StorageMetadata }; - try { - const result = await SecureStore.getItemAsync(key, rnsiOpts); - if (!result) return null; - data = JSON.parse(result) as { - value: string; - metadata: StorageMetadata; - }; - } catch (_error) { - return null; - } - return { key, service, ...data }; - }, - - deleteItem: async (key, options) => { - const rnsiOpts = convertOptions(options); - const service = options?.service ?? "default"; - await Promise.all([ - KVStore.removeItemAsync(`${service}-${key}`), - SecureStore.deleteItemAsync(key, rnsiOpts), - ]); - return true; - }, - - getAllItems: async (options) => { - const keys = await KVStore.getAllKeysAsync(); - const service = options?.service ?? "default"; - const metadata = createMetadata(options?.accessControl === "none"); - return keys - .filter((key) => key.startsWith(`${service}-`)) - .map((key) => ({ - key: key.slice(service.length + 1), - service, - metadata, - })); - }, - - clearService: async (options) => { - const rnsiOpts = convertOptions(options); - const service = options?.service ?? "default"; - const items = await store.getAllItems(options); - await KVStore.multiRemove(items.map((item) => `${service}-${item.key}`)); - await Promise.all( - items.map(async (item) => { - await SecureStore.deleteItemAsync(item.key, rnsiOpts); - }), - ); - }, - }; - - return store; -}; - -/** - * Create default metadata for backwards compatibility with items that don't - * have stored metadata. - */ -const createMetadata = (isSecure = true): SensitiveInfoItem["metadata"] => ({ - backend: "keychain", - accessControl: isSecure ? "biometryCurrentSet" : "none", - securityLevel: isSecure ? "biometry" : "software", - timestamp: Date.now(), -}); - -const convertOptions = ( - options?: LocalAuthOptions, -): SecureStore.SecureStoreOptions => { - const accessGroup = - options?.keychainGroup ?? localAuthDefaultOptions.keychainGroup ?? ""; - const keychainService = - options?.service ?? localAuthDefaultOptions.service ?? ""; - const keychainAccessible = convertKeychainAccessible( - options?.accessControl ?? - localAuthDefaultOptions.accessControl ?? - "biometryCurrentSet", - ); - const authenticationPrompt = - options?.authenticationPrompt?.title ?? - localAuthDefaultOptions.authenticationPrompt?.title ?? - ""; - return { - accessGroup, - keychainService, - keychainAccessible, - authenticationPrompt, - requireAuthentication: options?.accessControl !== "none", - }; -}; - -const convertKeychainAccessible = ( - accessControl: AccessControl, -): SecureStore.KeychainAccessibilityConstant => { - switch (accessControl) { - case "none": - // eslint-disable-next-line @typescript-eslint/no-deprecated - return SecureStore.ALWAYS; - case "biometryCurrentSet": - return SecureStore.AFTER_FIRST_UNLOCK; - case "biometryAny": - return SecureStore.AFTER_FIRST_UNLOCK; - case "devicePasscode": - return SecureStore.AFTER_FIRST_UNLOCK; - case "secureEnclaveBiometry": - return SecureStore.AFTER_FIRST_UNLOCK; - // Exhaustive check - default: - accessControl satisfies never; - // Default (for typescript, should never hit) - return SecureStore.AFTER_FIRST_UNLOCK; - } -}; - -const localAuth = createSharedLocalAuth(createSecureStore()); - -export const createExpoDeps = ( - deps: CreateSqliteDriverDep, -): { evoluReactNativeDeps: EvoluDeps; localAuth: LocalAuth } => ({ - evoluReactNativeDeps: createSharedEvoluDeps({ - ...deps, - reloadApp, - }), - localAuth, -}); +// /** +// * Create default metadata for backwards compatibility with items that don't +// * have stored metadata. +// */ +// const createMetadata = (isSecure = true): SensitiveInfoItem["metadata"] => ({ +// backend: "keychain", +// accessControl: isSecure ? "biometryCurrentSet" : "none", +// securityLevel: isSecure ? "biometry" : "software", +// timestamp: Date.now(), +// }); +// +// const convertOptions = ( +// options?: LocalAuthOptions, +// ): SecureStore.SecureStoreOptions => { +// const accessGroup = +// options?.keychainGroup ?? localAuthDefaultOptions.keychainGroup ?? ""; +// const keychainService = +// options?.service ?? localAuthDefaultOptions.service ?? ""; +// const keychainAccessible = convertKeychainAccessible( +// options?.accessControl ?? +// localAuthDefaultOptions.accessControl ?? +// "biometryCurrentSet", +// ); +// const authenticationPrompt = +// options?.authenticationPrompt?.title ?? +// localAuthDefaultOptions.authenticationPrompt?.title ?? +// ""; +// return { +// accessGroup, +// keychainService, +// keychainAccessible, +// authenticationPrompt, +// requireAuthentication: options?.accessControl !== "none", +// }; +// }; +// +// const convertKeychainAccessible = ( +// accessControl: AccessControl, +// ): SecureStore.KeychainAccessibilityConstant => { +// switch (accessControl) { +// case "none": +// // eslint-disable-next-line @typescript-eslint/no-deprecated +// return SecureStore.ALWAYS; +// case "biometryCurrentSet": +// return SecureStore.AFTER_FIRST_UNLOCK; +// case "biometryAny": +// return SecureStore.AFTER_FIRST_UNLOCK; +// case "devicePasscode": +// return SecureStore.AFTER_FIRST_UNLOCK; +// case "secureEnclaveBiometry": +// return SecureStore.AFTER_FIRST_UNLOCK; +// // Exhaustive check +// default: +// accessControl satisfies never; +// // Default (for typescript, should never hit) +// return SecureStore.AFTER_FIRST_UNLOCK; +// } +// }; +// +// const localAuth = createSharedLocalAuth(createSecureStore()); +// +// export const createExpoDeps = ( +// deps: CreateSqliteDriverDep, +// ): { evoluReactNativeDeps: EvoluDeps; localAuth: LocalAuth } => ({ +// evoluReactNativeDeps: createSharedEvoluDeps({ +// ...deps, +// reloadApp, +// }), +// localAuth, +// }); diff --git a/packages/react-native/src/exports/bare-op-sqlite.ts b/packages/react-native/src/exports/bare-op-sqlite.ts index 2cf2bdb33..8682c8ee2 100644 --- a/packages/react-native/src/exports/bare-op-sqlite.ts +++ b/packages/react-native/src/exports/bare-op-sqlite.ts @@ -9,8 +9,7 @@ import type { ReloadApp } from "@evolu/common"; import { DevSettings } from "react-native"; import { SensitiveInfo } from "react-native-sensitive-info"; -import { createSharedEvoluDeps, createSharedLocalAuth } from "../shared.js"; -import { createOpSqliteDriver } from "../sqlite-drivers/createOpSqliteDriver.js"; +import { createEvoluDeps, createSharedLocalAuth } from "../shared.js"; const reloadApp: ReloadApp = () => { if (process.env.NODE_ENV === "development") { @@ -21,10 +20,7 @@ const reloadApp: ReloadApp = () => { }; // eslint-disable-next-line evolu/require-pure-annotation -export const evoluReactNativeDeps = createSharedEvoluDeps({ - createSqliteDriver: createOpSqliteDriver, - reloadApp, -}); +export const evoluReactNativeDeps = createEvoluDeps({ reloadApp }); // eslint-disable-next-line evolu/require-pure-annotation export const localAuth = createSharedLocalAuth(SensitiveInfo); diff --git a/packages/react-native/src/exports/expo-op-sqlite.ts b/packages/react-native/src/exports/expo-op-sqlite.ts index 480bb7a4b..d2cecb91b 100644 --- a/packages/react-native/src/exports/expo-op-sqlite.ts +++ b/packages/react-native/src/exports/expo-op-sqlite.ts @@ -1,15 +1,15 @@ -/** - * Public entry point for Expo with OP-SQLite. Exported as - * "@evolu/react-native/expo-op-sqlite" in package.json. - * - * Use this with Expo projects that use `@op-engineering/op-sqlite` for better - * performance. - */ - -import { createExpoDeps } from "../createExpoDeps.js"; -import { createOpSqliteDriver } from "../sqlite-drivers/createOpSqliteDriver.js"; - -// eslint-disable-next-line evolu/require-pure-annotation -export const { evoluReactNativeDeps, localAuth } = createExpoDeps({ - createSqliteDriver: createOpSqliteDriver, -}); +// /** +// * Public entry point for Expo with OP-SQLite. Exported as +// * "@evolu/react-native/expo-op-sqlite" in package.json. +// * +// * Use this with Expo projects that use `@op-engineering/op-sqlite` for better +// * performance. +// */ +// +// import { createExpoDeps } from "../createExpoDeps.js"; +// import { createOpSqliteDriver } from "../sqlite-drivers/createOpSqliteDriver.js"; +// +// // eslint-disable-next-line evolu/require-pure-annotation +// export const { evoluReactNativeDeps, localAuth } = createExpoDeps({ +// createSqliteDriver: createOpSqliteDriver, +// }); diff --git a/packages/react-native/src/exports/expo-sqlite.ts b/packages/react-native/src/exports/expo-sqlite.ts index 337dc9d64..e6369bb9b 100644 --- a/packages/react-native/src/exports/expo-sqlite.ts +++ b/packages/react-native/src/exports/expo-sqlite.ts @@ -5,10 +5,22 @@ * Use this with Expo projects that use expo-sqlite. */ -import { createExpoDeps } from "../createExpoDeps.js"; -import { createExpoSqliteDriver } from "../sqlite-drivers/createExpoSqliteDriver.js"; +import type { EvoluDeps } from "@evolu/common/local-first"; +import * as Expo from "expo"; +import { createEvoluDeps as createSharedEvoluDeps } from "../shared.js"; -// eslint-disable-next-line evolu/require-pure-annotation -export const { evoluReactNativeDeps, localAuth } = createExpoDeps({ - createSqliteDriver: createExpoSqliteDriver, -}); +/** Creates Evolu dependencies for Expo. */ +export const createEvoluDeps = (): EvoluDeps => + createSharedEvoluDeps({ + reloadApp: () => { + void Expo.reloadAppAsync(); + }, + }); + +// import { createExpoDeps } from "../createExpoDeps.js"; +// import { createExpoSqliteDriver } from "../sqlite-drivers/createExpoSqliteDriver.js"; +// +// // eslint-disable-next-line evolu/require-pure-annotation +// export const { evoluReactNativeDeps, localAuth } = createExpoDeps({ +// createSqliteDriver: createExpoSqliteDriver, +// }); diff --git a/packages/react-native/src/shared.ts b/packages/react-native/src/shared.ts index df6eaeeb8..e85370d72 100644 --- a/packages/react-native/src/shared.ts +++ b/packages/react-native/src/shared.ts @@ -1,27 +1,51 @@ import { - type CreateSqliteDriverDep, - createConsole, createLocalAuth, createRandomBytes, type LocalAuth, type ReloadAppDep, type SecureStorage, } from "@evolu/common"; -import type { - // createDbWorkerForPlatform, - // createDbWorkerForPlatform, - EvoluDeps, -} from "@evolu/common/local-first"; +import type { EvoluDeps } from "@evolu/common/local-first"; +import { createEvoluDeps as createCommonEvoluDeps } from "@evolu/common/local-first"; -const _console = createConsole(); const randomBytes = createRandomBytes(); -export const createSharedEvoluDeps = ( - _deps: CreateSqliteDriverDep & ReloadAppDep, -): EvoluDeps => { - throw new Error("todo"); -}; +/** Creates Evolu dependencies for React Native. */ +export const createEvoluDeps = (deps: ReloadAppDep): EvoluDeps => + createCommonEvoluDeps(deps); +export const createSharedLocalAuth = ( + secureStorage: SecureStorage, +): LocalAuth => + createLocalAuth({ + randomBytes, + secureStorage, + }); + +// import { +// createConsole, +// createLocalAuth, +// createRandomBytes, +// type CreateSqliteDriverDep, +// type LocalAuth, +// type ReloadAppDep, +// type SecureStorage, +// } from "@evolu/common"; +// import type { +// // createDbWorkerForPlatform, +// // createDbWorkerForPlatform, +// EvoluDeps, +// } from "@evolu/common/local-first"; +// +// const _console = createConsole(); +// const randomBytes = createRandomBytes(); +// +// export const createSharedEvoluDeps = ( +// _deps: CreateSqliteDriverDep & ReloadAppDep, +// ): EvoluDeps => { +// throw new Error("todo"); +// }; +// // ({ // ...deps, // console, @@ -37,11 +61,3 @@ export const createSharedEvoluDeps = ( // // }), // randomBytes, // }); - -export const createSharedLocalAuth = ( - secureStorage: SecureStorage, -): LocalAuth => - createLocalAuth({ - randomBytes, - secureStorage, - }); diff --git a/packages/react-web/src/local-first/Evolu.ts b/packages/react-web/src/local-first/Evolu.ts new file mode 100644 index 000000000..6e716c7d1 --- /dev/null +++ b/packages/react-web/src/local-first/Evolu.ts @@ -0,0 +1,9 @@ +import type { EvoluDeps } from "@evolu/common/local-first"; +import { createEvoluDeps as createWebEvoluDeps } from "@evolu/web"; +import { flushSync } from "react-dom"; + +/** Creates Evolu dependencies for web with React DOM flush sync. */ +export const createEvoluDeps = (): EvoluDeps => { + const deps = createWebEvoluDeps(); + return { ...deps, flushSync }; +}; diff --git a/packages/web/src/local-first/Evolu.ts b/packages/web/src/local-first/Evolu.ts index eb44651ef..7db83aca7 100644 --- a/packages/web/src/local-first/Evolu.ts +++ b/packages/web/src/local-first/Evolu.ts @@ -1,28 +1,25 @@ -import { createLocalAuth, createRandomBytes } from "@evolu/common"; -import type { EvoluDeps, EvoluWorkerInput } from "@evolu/common/local-first"; +import type { EvoluDeps } from "@evolu/common/local-first"; import { createEvoluDeps as createCommonEvoluDeps } from "@evolu/common/local-first"; import { reloadApp } from "../Platform.js"; -import { createMessageChannel, createSharedWorker } from "../Worker.js"; -import { createWebAuthnStore } from "./LocalAuth.js"; -// TODO: Redesign. -// eslint-disable-next-line evolu/require-pure-annotation -export const localAuth = createLocalAuth({ - randomBytes: createRandomBytes(), - secureStorage: createWebAuthnStore({ randomBytes: createRandomBytes() }), -}); +// // TODO: Redesign. +// // eslint-disable-next-line evolu/require-pure-annotation +// export const localAuth = createLocalAuth({ +// randomBytes: createRandomBytes(), +// secureStorage: createWebAuthnStore({ randomBytes: createRandomBytes() }), +// }); /** Creates Evolu dependencies for the web platform. */ -export const createEvoluDeps = (): EvoluDeps => { - const evoluWorker = createSharedWorker( - new SharedWorker(new URL("Worker.worker.js", import.meta.url), { - type: "module", - }), - ); +export const createEvoluDeps = (): EvoluDeps => + createCommonEvoluDeps({ reloadApp }); +// const evoluWorker = createSharedWorker( +// new SharedWorker(new URL("Worker.worker.js", import.meta.url), { +// type: "module", +// }), +// ); - return createCommonEvoluDeps({ - createMessageChannel, - reloadApp, - evoluWorker, - }); -}; +// return createCommonEvoluDeps({ +// createMessageChannel, +// reloadApp, +// evoluWorker, +// }); From 425f86ee5d36d168c02a84843902240c85462b40 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 11:56:38 +0100 Subject: [PATCH 18/37] Restructured React hooks into local-first directory Moved hooks from src/ to src/local-first/ to mirror common package structure. Replaced EvoluProvider/useEvolu/createUseEvolu pattern with createEvoluContext using React use() and Context. Added createRunContext for typed Run context. Removed useEvoluError (error store redesign). Updated index exports (cherry picked from commit ccf4da1c7bb79a72d02fd0150566705abd8f4278) --- packages/react/src/EvoluContext.ts | 6 --- packages/react/src/EvoluProvider.tsx | 13 ----- packages/react/src/Task.tsx | 35 +++++++++++++ packages/react/src/createUseEvolu.ts | 17 ------- packages/react/src/index.ts | 19 ++++--- .../react/src/local-first/EvoluContext.tsx | 50 +++++++++++++++++++ .../react/src/{ => local-first}/useIsSsr.ts | 0 .../react/src/{ => local-first}/useOwner.ts | 6 +-- .../react/src/{ => local-first}/useQueries.ts | 6 ++- .../react/src/{ => local-first}/useQuery.ts | 4 +- .../{ => local-first}/useQuerySubscription.ts | 6 +-- .../src/{ => local-first}/useSyncState.ts | 5 +- packages/react/src/useEvolu.ts | 6 +-- 13 files changed, 111 insertions(+), 62 deletions(-) delete mode 100644 packages/react/src/EvoluContext.ts delete mode 100644 packages/react/src/EvoluProvider.tsx create mode 100644 packages/react/src/Task.tsx delete mode 100644 packages/react/src/createUseEvolu.ts create mode 100644 packages/react/src/local-first/EvoluContext.tsx rename packages/react/src/{ => local-first}/useIsSsr.ts (100%) rename packages/react/src/{ => local-first}/useOwner.ts (79%) rename packages/react/src/{ => local-first}/useQueries.ts (94%) rename packages/react/src/{ => local-first}/useQuery.ts (95%) rename packages/react/src/{ => local-first}/useQuerySubscription.ts (89%) rename packages/react/src/{ => local-first}/useSyncState.ts (73%) diff --git a/packages/react/src/EvoluContext.ts b/packages/react/src/EvoluContext.ts deleted file mode 100644 index 6e7bdb458..000000000 --- a/packages/react/src/EvoluContext.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Evolu } from "@evolu/common/local-first"; -import { createContext } from "react"; - -export const EvoluContext = /*#__PURE__*/ createContext | null>( - null, -); diff --git a/packages/react/src/EvoluProvider.tsx b/packages/react/src/EvoluProvider.tsx deleted file mode 100644 index ca4c0bc17..000000000 --- a/packages/react/src/EvoluProvider.tsx +++ /dev/null @@ -1,13 +0,0 @@ -"use client"; - -import type { Evolu } from "@evolu/common/local-first"; -import type { ReactNode } from "react"; -import { EvoluContext } from "./EvoluContext.js"; - -export const EvoluProvider = ({ - children, - value, -}: { - readonly children?: ReactNode | undefined; - readonly value: Evolu; -}): React.ReactElement => {children}; diff --git a/packages/react/src/Task.tsx b/packages/react/src/Task.tsx new file mode 100644 index 000000000..9c14d8e84 --- /dev/null +++ b/packages/react/src/Task.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { testCreateRun, type Run } from "@evolu/common"; +import { createContext, type ReactNode } from "react"; + +const RunContext = /*#__PURE__*/ createContext(testCreateRun()); + +/** + * Creates typed React Context and Provider for {@link Run}. + * + * ### Example + * + * ```tsx + * const run = createRun(createEvoluDeps()); + * const { Run, RunProvider } = createRunContext(run); + * + * + * + * ; + * + * // In a component + * const run = use(Run); + * ``` + */ +export const createRunContext = ( + run: Run, +): { + readonly Run: React.Context>; + readonly RunProvider: React.FC<{ readonly children?: ReactNode }>; +} => ({ + Run: RunContext as React.Context>, + RunProvider: ({ children }) => ( + {children} + ), +}); diff --git a/packages/react/src/createUseEvolu.ts b/packages/react/src/createUseEvolu.ts deleted file mode 100644 index 76897ffe2..000000000 --- a/packages/react/src/createUseEvolu.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Evolu, EvoluSchema } from "@evolu/common"; -import { useEvolu } from "./useEvolu.js"; - -/** - * Creates a typed React Hook returning an instance of {@link Evolu}. - * - * ### Example - * - * ```ts - * const useEvolu = createUseEvolu(evolu); - * const { insert, update } = useEvolu(); - * ``` - */ -export const createUseEvolu = ( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - evolu: Evolu, -): (() => Evolu) => useEvolu as () => Evolu; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e3df8e978..c91fa40b1 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,12 +1,11 @@ -export * from "./createUseEvolu.js"; -export * from "./EvoluContext.js"; -export * from "./EvoluProvider.js"; +export * from "./local-first/EvoluContext.js"; +export * from "./local-first/useIsSsr.js"; +export * from "./local-first/useOwner.js"; +export * from "./local-first/useQueries.js"; +export * from "./local-first/useQuery.js"; +export * from "./local-first/useQuerySubscription.js"; +export * from "./Task.js"; export * from "./useEvolu.js"; export * from "./useEvoluError.js"; -// TODO: Re-enable useSyncState export after owner-api refactoring is complete -// export * from "./useSyncState.js"; -export * from "./useIsSsr.js"; -export * from "./useOwner.js"; -export * from "./useQueries.js"; -export * from "./useQuery.js"; -export * from "./useQuerySubscription.js"; + +// export * from "./local-first/useSyncState.js"; TODO: Update it for the owner-api diff --git a/packages/react/src/local-first/EvoluContext.tsx b/packages/react/src/local-first/EvoluContext.tsx new file mode 100644 index 000000000..49ce21c37 --- /dev/null +++ b/packages/react/src/local-first/EvoluContext.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { assert, type Result } from "@evolu/common"; +import type { Evolu, EvoluSchema } from "@evolu/common/local-first"; +import { createContext, use, type ReactNode } from "react"; + +export const EvoluContext = /*#__PURE__*/ createContext(null as never); + +/** + * Creates typed React Context and Provider for {@link Evolu}. + * + * Returns a tuple for easy renaming when using multiple Evolu instances. + * + * The provider internally uses React's `use()` to unwrap the Fiber, so it must + * be wrapped in a Suspense boundary. + * + * ### Example + * + * ```tsx + * const app = run(Evolu.createEvolu(Schema, {...})); + * const [App, AppProvider] = createEvoluContext(app); + * + * // Multiple instances with custom names + * const [TodoEvolu, TodoEvoluProvider] = createEvoluContext(todo); + * const [NotesEvolu, NotesEvoluProvider] = createEvoluContext(notes); + * + * + * + * + * + * ; + * + * // In a component + * const evolu = use(App); + * ``` + */ +export const createEvoluContext = ( + fiber: PromiseLike, unknown>>, +): readonly [ + React.Context>, + React.FC<{ readonly children?: ReactNode }>, +] => [ + EvoluContext as React.Context>, + ({ children }) => { + const result = use(fiber); + assert(result.ok, "createEvolu failed"); + + return {children}; + }, +]; diff --git a/packages/react/src/useIsSsr.ts b/packages/react/src/local-first/useIsSsr.ts similarity index 100% rename from packages/react/src/useIsSsr.ts rename to packages/react/src/local-first/useIsSsr.ts diff --git a/packages/react/src/useOwner.ts b/packages/react/src/local-first/useOwner.ts similarity index 79% rename from packages/react/src/useOwner.ts rename to packages/react/src/local-first/useOwner.ts index 136076754..c0707fe50 100644 --- a/packages/react/src/useOwner.ts +++ b/packages/react/src/local-first/useOwner.ts @@ -1,6 +1,6 @@ import type { SyncOwner } from "@evolu/common"; -import { useEffect } from "react"; -import { useEvolu } from "./useEvolu.js"; +import { use, useEffect } from "react"; +import { EvoluContext } from "./EvoluContext.js"; /** * React Hook for Evolu `useOwner` method. @@ -9,7 +9,7 @@ import { useEvolu } from "./useEvolu.js"; * defined in Evolu config if the owner has no transports defined. */ export const useOwner = (owner: SyncOwner | null): void => { - const evolu = useEvolu(); + const evolu = use(EvoluContext); useEffect(() => { if (owner == null) return; diff --git a/packages/react/src/useQueries.ts b/packages/react/src/local-first/useQueries.ts similarity index 94% rename from packages/react/src/useQueries.ts rename to packages/react/src/local-first/useQueries.ts index 21a3947e9..ad24db217 100644 --- a/packages/react/src/useQueries.ts +++ b/packages/react/src/local-first/useQueries.ts @@ -5,10 +5,10 @@ import type { Row, } from "@evolu/common/local-first"; import { use, useRef } from "react"; -import { useEvolu } from "./useEvolu.js"; import { useIsSsr } from "./useIsSsr.js"; import type { useQuery } from "./useQuery.js"; import { useQuerySubscription } from "./useQuerySubscription.js"; +import { EvoluContext } from "./EvoluContext.js"; /** * The same as {@link useQuery}, but for many queries. @@ -32,9 +32,10 @@ export const useQueries = < ]; }> = {}, ): [...QueriesToQueryRows, ...QueriesToQueryRows] => { - const evolu = useEvolu(); + const evolu = use(EvoluContext); const once = useRef(options).current.once; const allQueries = once ? queries.concat(once) : queries; + const wasSSR = useIsSsr(); if (wasSSR) { if (!options.promises) void evolu.loadQueries(allQueries); @@ -44,6 +45,7 @@ export const useQueries = < // so React suspends once and all promises resolve together. else evolu.loadQueries(allQueries).map(use); } + return allQueries.map((query, i) => // Safe until the number of queries is stable. // biome-ignore lint/correctness/useHookAtTopLevel: intentional diff --git a/packages/react/src/useQuery.ts b/packages/react/src/local-first/useQuery.ts similarity index 95% rename from packages/react/src/useQuery.ts rename to packages/react/src/local-first/useQuery.ts index 7bcf614ea..daef0a8c7 100644 --- a/packages/react/src/useQuery.ts +++ b/packages/react/src/local-first/useQuery.ts @@ -1,6 +1,6 @@ import type { Query, QueryRows, Row } from "@evolu/common/local-first"; import { use } from "react"; -import { useEvolu } from "./useEvolu.js"; +import { EvoluContext } from "./EvoluContext.js"; import { useIsSsr } from "./useIsSsr.js"; import type { useQueries } from "./useQueries.js"; import { useQuerySubscription } from "./useQuerySubscription.js"; @@ -44,7 +44,7 @@ export const useQuery = ( readonly promise: Promise>; }> = {}, ): QueryRows => { - const evolu = useEvolu(); + const evolu = use(EvoluContext); const isSSR = useIsSsr(); if (isSSR) { diff --git a/packages/react/src/useQuerySubscription.ts b/packages/react/src/local-first/useQuerySubscription.ts similarity index 89% rename from packages/react/src/useQuerySubscription.ts rename to packages/react/src/local-first/useQuerySubscription.ts index b315cbd67..783d23e79 100644 --- a/packages/react/src/useQuerySubscription.ts +++ b/packages/react/src/local-first/useQuerySubscription.ts @@ -5,8 +5,8 @@ import { type QueryRows, type Row, } from "@evolu/common/local-first"; -import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; -import { useEvolu } from "./useEvolu.js"; +import { use, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import { EvoluContext } from "./EvoluContext.js"; /** Subscribe to {@link Query} {@link QueryRows} changes. */ export const useQuerySubscription = ( @@ -19,7 +19,7 @@ export const useQuerySubscription = ( readonly once: boolean; }> = {}, ): QueryRows => { - const evolu = useEvolu(); + const evolu = use(EvoluContext); // useRef to not break "rules-of-hooks" const { once } = useRef(options).current; diff --git a/packages/react/src/useSyncState.ts b/packages/react/src/local-first/useSyncState.ts similarity index 73% rename from packages/react/src/useSyncState.ts rename to packages/react/src/local-first/useSyncState.ts index 29b2ed7d6..803e76c88 100644 --- a/packages/react/src/useSyncState.ts +++ b/packages/react/src/local-first/useSyncState.ts @@ -1,9 +1,10 @@ import type { SyncState } from "@evolu/common/local-first"; -import { useEvolu } from "./useEvolu.js"; +import { use } from "react"; +import { EvoluContext } from "./EvoluContext.js"; /** Subscribe to {@link SyncState} changes. */ export const useSyncState = (): SyncState => { - const _evolu = useEvolu(); + const _evolu = use(EvoluContext); // return useSyncExternalStore( // evolu.subscribeSyncState, // evolu.getSyncState, diff --git a/packages/react/src/useEvolu.ts b/packages/react/src/useEvolu.ts index b49a4a979..4b2d72fd4 100644 --- a/packages/react/src/useEvolu.ts +++ b/packages/react/src/useEvolu.ts @@ -1,13 +1,11 @@ import type { Evolu } from "@evolu/common/local-first"; import { useContext } from "react"; -import type { createUseEvolu } from "./createUseEvolu.js"; -import { EvoluContext } from "./EvoluContext.js"; +import { EvoluContext } from "./local-first/EvoluContext.js"; /** * React Hook returning a generic instance of {@link Evolu}. * - * This is intended for internal usage. Applications should use - * {@link createUseEvolu}, which provides a correctly typed instance. + * This is intended for internal usage. */ export const useEvolu = (): Evolu => { const evolu = useContext(EvoluContext); From a301d17f99ccec94e036a5c4f205d03db650e82f Mon Sep 17 00:00:00 2001 From: Miccy Date: Sat, 7 Feb 2026 21:00:14 +0100 Subject: [PATCH 19/37] fix(sync): bridge Run naming and apply React hook formatting --- packages/common/src/Task.ts | 3 +++ packages/react/src/Task.tsx | 2 +- packages/react/src/local-first/EvoluContext.tsx | 2 +- packages/react/src/local-first/useQueries.ts | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/common/src/Task.ts b/packages/common/src/Task.ts index 20e7b60e4..812e053c0 100644 --- a/packages/common/src/Task.ts +++ b/packages/common/src/Task.ts @@ -677,6 +677,9 @@ export interface Runner extends AsyncDisposable { readonly addDeps: >(extraDeps: E) => Runner; } +/** Backward-compatible alias for upstream naming. */ +export type Run = Runner; + /** * Abort mask depth for a {@link Runner} or {@link Fiber}. * diff --git a/packages/react/src/Task.tsx b/packages/react/src/Task.tsx index 9c14d8e84..286a22cad 100644 --- a/packages/react/src/Task.tsx +++ b/packages/react/src/Task.tsx @@ -1,6 +1,6 @@ "use client"; -import { testCreateRun, type Run } from "@evolu/common"; +import { type Run, testCreateRun } from "@evolu/common"; import { createContext, type ReactNode } from "react"; const RunContext = /*#__PURE__*/ createContext(testCreateRun()); diff --git a/packages/react/src/local-first/EvoluContext.tsx b/packages/react/src/local-first/EvoluContext.tsx index 49ce21c37..60e181073 100644 --- a/packages/react/src/local-first/EvoluContext.tsx +++ b/packages/react/src/local-first/EvoluContext.tsx @@ -2,7 +2,7 @@ import { assert, type Result } from "@evolu/common"; import type { Evolu, EvoluSchema } from "@evolu/common/local-first"; -import { createContext, use, type ReactNode } from "react"; +import { createContext, type ReactNode, use } from "react"; export const EvoluContext = /*#__PURE__*/ createContext(null as never); diff --git a/packages/react/src/local-first/useQueries.ts b/packages/react/src/local-first/useQueries.ts index ad24db217..0bb8a5c35 100644 --- a/packages/react/src/local-first/useQueries.ts +++ b/packages/react/src/local-first/useQueries.ts @@ -5,10 +5,10 @@ import type { Row, } from "@evolu/common/local-first"; import { use, useRef } from "react"; +import { EvoluContext } from "./EvoluContext.js"; import { useIsSsr } from "./useIsSsr.js"; import type { useQuery } from "./useQuery.js"; import { useQuerySubscription } from "./useQuerySubscription.js"; -import { EvoluContext } from "./EvoluContext.js"; /** * The same as {@link useQuery}, but for many queries. From 1f9d9345119ad22605f27592ced6395ecf359196 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 11:57:10 +0100 Subject: [PATCH 20/37] Updated Svelte appOwnerState for synchronous appOwner Changed appOwnerState to use evolu.appOwner directly instead of awaiting a promise (cherry picked from commit 83f6d3d71b07f19873254b66b97e41e591a9a1d7) # Conflicts: # packages/svelte/src/lib/index.svelte.ts From dbf53393cc3415581f68ae254de3aed0c1be375f Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 11:57:31 +0100 Subject: [PATCH 21/37] Removed useEvoluError from Vue and sorted exports Deleted useEvoluError (error store being redesigned) and alphabetically sorted exports in index.ts (cherry picked from commit 304da2d479bb183c0009e7c43fdc9a70ff3a50c7) --- packages/vue/src/index.ts | 1 - packages/vue/src/useEvoluError.ts | 17 ----------------- 2 files changed, 18 deletions(-) delete mode 100644 packages/vue/src/useEvoluError.ts diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index f763257ce..59d2b9b0c 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -2,7 +2,6 @@ export * from "./createUseEvolu.js"; export * from "./EvoluProvider.js"; export * from "./provideEvolu.js"; export * from "./useEvolu.js"; -export * from "./useEvoluError.js"; export * from "./useOwner.js"; export * from "./useQueries.js"; export * from "./useQuery.js"; diff --git a/packages/vue/src/useEvoluError.ts b/packages/vue/src/useEvoluError.ts deleted file mode 100644 index 0b77b11c5..000000000 --- a/packages/vue/src/useEvoluError.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { EvoluError } from "@evolu/common"; -import { onScopeDispose, type Ref, ref } from "vue"; -import { useEvolu } from "./useEvolu.js"; - -/** Subscribe to {@link EvoluError} changes. */ -export const useEvoluError = (): Ref => { - const evolu = useEvolu(); - const error = ref(evolu.getError()); - - const unsubscribe = evolu.subscribeError(() => { - error.value = evolu.getError(); - }); - - onScopeDispose(unsubscribe); - - return error; -}; From eb0fce9a45fa3b91fcf4f80e724ab7a8c8bca191 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 11:59:28 +0100 Subject: [PATCH 22/37] Update tsconfig.json (cherry picked from commit 6c793b2a35241be2c9b8bf727bda9cadb770b60a) --- apps/web/tsconfig.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 28f79954c..f4983201a 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -8,15 +8,13 @@ { "name": "next" } - ], - "strictNullChecks": true + ] }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.mts", - "next.config.ts", ".next/types/**/*.ts", "tailwind.config.js" ], From da49241ef663b1abd078ca4604ac1ef1fdc0be1b Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 11:59:41 +0100 Subject: [PATCH 23/37] Update tsconfig.json (cherry picked from commit 6d98bd27668db06eca26b339a127e1a13ca9ebf9) --- apps/relay/tsconfig.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/relay/tsconfig.json b/apps/relay/tsconfig.json index 4a3b5115a..ec2fb1565 100644 --- a/apps/relay/tsconfig.json +++ b/apps/relay/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../packages/tsconfig/universal-esm.json", "compilerOptions": { - "outDir": "dist", - "module": "Node16" + "outDir": "dist" }, "include": ["src"], "exclude": ["node_modules"] From 151a17e98019ec40ddaec67075b73aa00bceebe4 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 12:00:21 +0100 Subject: [PATCH 24/37] Update index.tsx (cherry picked from commit 1563099004a9e91bd19681a0b880386240de5549) --- examples/react-expo/app/index.tsx | 1767 +++++++++++++++-------------- 1 file changed, 939 insertions(+), 828 deletions(-) diff --git a/examples/react-expo/app/index.tsx b/examples/react-expo/app/index.tsx index 0813afd7f..37c5ae8ba 100644 --- a/examples/react-expo/app/index.tsx +++ b/examples/react-expo/app/index.tsx @@ -1,34 +1,17 @@ -import Alert from "@blazejkustra/react-native-alert"; import * as Evolu from "@evolu/common"; -import { createUseEvolu, EvoluProvider, useQuery } from "@evolu/react"; -import { EvoluIdenticon } from "@evolu/react-native"; -import { - evoluReactNativeDeps, - localAuth, -} from "@evolu/react-native/expo-sqlite"; -import { type FC, Suspense, use, useEffect, useMemo, useState } from "react"; -import { - ActivityIndicator, - ScrollView, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; - -// Namespace for the current app (scopes databases, passkeys, etc.) -const service = "rn-expo"; +import { createEvoluContext } from "@evolu/react"; +import { createRun } from "@evolu/react-native"; +import { createEvoluDeps } from "@evolu/react-native/expo-sqlite"; +import { Suspense, use } from "react"; +import { Text, View } from "react-native"; // Primary keys are branded types, preventing accidental use of IDs across // different tables (e.g., a TodoId can't be used where a UserId is expected). const TodoId = Evolu.id("Todo"); -// biome-ignore lint/correctness/noUnusedVariables: Context type TodoId = typeof TodoId.Type; // Schema defines database structure with runtime validation. -// Column types validate data on insert/update/upsert. +// Column types validate data on insert/update/upsert/sync. const Schema = { todo: { id: TodoId, @@ -39,820 +22,948 @@ const Schema = { }, }; -export default function Index(): React.ReactNode { - const [authResult, setAuthResult] = useState(null); - const [ownerIds, setOwnerIds] = useState | null>(null); - const [evolu, setEvolu] = useState | null>(null); - - useEffect(() => { - (async () => { - const authResult = await localAuth.getOwner({ service }); - const ownerIds = await localAuth.getProfiles({ service }); - const evolu = Evolu.createEvolu(evoluReactNativeDeps)(Schema, { - name: Evolu.SimpleName.orThrow( - `${service}-${authResult?.owner?.id ?? "guest"}`, - ), - encryptionKey: authResult?.owner?.encryptionKey, - externalAppOwner: authResult?.owner, - // ...(process.env.NODE_ENV === "development" && { - // transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], - // }), - }); - - setEvolu(evolu as Evolu.Evolu); - setOwnerIds(ownerIds); - setAuthResult(authResult); +// Create Run with Evolu dependencies for React Native. +const run = createRun(createEvoluDeps()); - /** - * Subscribe to unexpected Evolu errors (database, network, sync issues). - * These should not happen in normal operation, so always log them for - * debugging. Show users a friendly error message instead of technical - * details. - */ - return evolu.subscribeError(() => { - const error = evolu.getError(); - if (!error) return; - Alert.alert("🚨 Evolu error occurred! Check the console."); - // eslint-disable-next-line no-console - console.error(error); - }); - })().catch((error) => { - console.error(error); - }); - }, []); +// Create Evolu App. +const app = run( + Evolu.createEvolu(Schema, { + name: Evolu.SimpleName.orThrow("rn-expo"), + }), +); - if (evolu == null) { - return ( - - - - ); - } +const [App, AppProvider] = createEvoluContext(app); +export default function Index() { return ( - - - + + + + + ); } -const EvoluDemo = ({ - evolu, - ownerIds, - authResult, -}: { - evolu: Evolu.Evolu; - ownerIds: Array | null; - authResult: Evolu.AuthResult | null; -}): React.ReactNode => { - const useEvolu = createUseEvolu(evolu); - - // Create a query builder (once per schema). - const createQuery = Evolu.createQueryBuilder(Schema); - - // Evolu uses Kysely for type-safe SQL (https://kysely.dev/). - const todosQuery = createQuery((db) => - db - // Type-safe SQL: try autocomplete for table and column names. - .selectFrom("todo") - .select(["id", "title", "isCompleted"]) - // Soft delete: filter out deleted rows. - .where("isDeleted", "is not", Evolu.sqliteTrue) - // Like with GraphQL, all columns except id are nullable in queries - // (even if defined without nullOr in the schema) to allow schema - // evolution without migrations. Filter nulls with where + $narrowType. - .where("title", "is not", null) - .$narrowType<{ title: Evolu.kysely.NotNull }>() - // Columns createdAt, updatedAt, isDeleted are auto-added to all tables. - .orderBy("createdAt"), - ); - - // Extract the row type from the query for type-safe component props. - type TodosRow = typeof todosQuery.Row; - - const Todos: FC = () => { - // useQuery returns live data - component re-renders when data changes. - const todos = useQuery(todosQuery); - const { insert } = useEvolu(); - const [newTodoTitle, setNewTodoTitle] = useState(""); - - const handleAddTodo = () => { - const result = insert( - "todo", - { title: newTodoTitle.trim() }, - { - onComplete: () => { - setNewTodoTitle(""); - }, - }, - ); - - if (!result.ok) { - Alert.alert("Error", formatTypeError(result.error)); - } - }; - - return ( - 0 ? 6 : 24 }, - ]} - > - 0 ? "flex" : "none" }, - ]} - > - {todos.map((todo) => ( - - ))} - - - - - - - - ); - }; - - const TodoItem: FC<{ - row: TodosRow; - }> = ({ row: { id, title, isCompleted } }) => { - const { update } = useEvolu(); - - const handleToggleCompletedPress = () => { - update("todo", { - id, - isCompleted: Evolu.booleanToSqliteBoolean(!isCompleted), - }); - }; - - const handleRenamePress = () => { - Alert.prompt( - "Edit Todo", - "Enter new title:", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Save", - onPress: (newTitle?: string) => { - if (newTitle?.trim()) { - const result = update("todo", { id, title: newTitle.trim() }); - if (!result.ok) { - Alert.alert("Error", formatTypeError(result.error)); - } - } - }, - }, - ], - "plain-text", - title, - ); - }; - - const handleDeletePress = () => { - update("todo", { - id, - // Soft delete with isDeleted flag (CRDT-friendly, preserves sync history). - isDeleted: Evolu.sqliteTrue, - }); - }; - - return ( - - - - - ✓ - - - - {title} - - - - - - ✏️ - - - 🗑️ - - - - ); - }; - - const OwnerActions: FC = () => { - const evolu = useEvolu(); - const appOwner = use(evolu.appOwner); - const [showMnemonic, setShowMnemonic] = useState(false); - - // Restore owner from mnemonic to sync data across devices. - const handleRestoreAppOwnerPress = () => { - Alert.prompt( - "Restore Account", - "Enter your mnemonic to restore your data:", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Restore", - onPress: (mnemonic?: string) => { - if (mnemonic == null) return; - - const result = Evolu.Mnemonic.from(mnemonic.trim()); - if (!result.ok) { - Alert.alert("Error", formatTypeError(result.error)); - return; - } - - void evolu.restoreAppOwner(result.value); - }, - }, - ], - "plain-text", - ); - }; - - const handleResetAppOwnerPress = () => { - Alert.alert( - "Reset All Data", - "Are you sure? This will delete all your local data.", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Reset", - style: "destructive", - onPress: () => { - void evolu.resetAppOwner(); - }, - }, - ], - ); - }; - - return ( - - Account - {appOwner && ( - - - - )} - - Todos are stored in local SQLite. When you sync across devices, your - data is end-to-end encrypted using your mnemonic. - - - - { - setShowMnemonic(!showMnemonic); - }} - style={styles.fullWidthButton} - /> - - {showMnemonic && appOwner?.mnemonic && ( - - - Your Mnemonic (keep this safe!) - - - - )} - - - - - - - - ); - }; - - const AuthActions: FC = () => { - const appOwner = use(evolu.appOwner); - // biome-ignore lint/correctness/useExhaustiveDependencies: Found ownerIds in outer scope - const otherOwnerIds = useMemo( - () => ownerIds?.filter(({ ownerId }) => ownerId !== appOwner?.id) ?? [], - [appOwner?.id, ownerIds], - ); - - // Create a new owner and register it to a passkey. - const handleRegisterPress = async () => { - Alert.prompt( - "Register Passkey", - "Enter your username:", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Register", - onPress: async (username?: string) => { - if (username == null) return; - - // Determine if this is a guest login or a new owner. - const isGuest = !authResult?.owner; - - // Register the guest owner or create a new one if this is already registered. - const mnemonic = isGuest ? appOwner?.mnemonic : undefined; - const result = await localAuth.register(username, { - service, - mnemonic, - }); - if (result) { - // If this is a guest owner, we should clear the database and reload. - // The owner is transferred to a new database on next login. - if (isGuest) { - evolu.resetAppOwner({ reload: true }); - // Otherwise, just reload the app (in RN, we can't reload like web) - } else { - evolu.reloadApp(); - } - } else { - Alert.alert("Error", "Failed to register profile"); - } - }, - }, - ], - "plain-text", - ); - }; - - // Login with a specific owner id using the registered passkey. - const handleLoginPress = async (ownerId: Evolu.OwnerId) => { - const result = await localAuth.login(ownerId, { service }); - if (result) { - evolu.reloadApp(); - } else { - Alert.alert("Error", "Failed to login"); - } - }; - - // Clear all data including passkeys and metadata. - const handleClearAllPress = async () => { - Alert.alert( - "Clear All Data", - "Are you sure you want to clear all data? This will remove all passkeys and cannot be undone.", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Clear", - style: "destructive", - onPress: async () => { - await localAuth.clearAll({ service }); - void evolu.resetAppOwner({ reload: true }); - }, - }, - ], - ); - }; - - return ( - - Passkeys - - Register a new passkey or choose a previously registered one. - - - - - - {otherOwnerIds.length > 0 && ( - - {otherOwnerIds.map(({ ownerId, username }) => ( - - ))} - - )} - - ); - }; - - const OwnerProfile: FC<{ - ownerId: Evolu.OwnerId; - username: string; - handleLoginPress?: (ownerId: Evolu.OwnerId) => void; - }> = ({ ownerId, username, handleLoginPress }) => { - return ( - - - - - {username} - - {ownerId as string} - - - - {handleLoginPress && ( - handleLoginPress(ownerId)} - style={styles.loginButton} - /> - )} - - ); - }; - - const CustomButton: FC<{ - title: string; - style?: any; - onPress: () => void; - variant?: "primary" | "secondary"; - }> = ({ title, style, onPress, variant = "secondary" }) => { - const buttonStyle = [ - styles.button, - variant === "primary" ? styles.buttonPrimary : styles.buttonSecondary, - style, - ]; - - const textStyle = [ - styles.buttonText, - variant === "primary" - ? styles.buttonTextPrimary - : styles.buttonTextSecondary, - ]; - - return ( - - {title} - - ); - }; +const Test = () => { + const app = use(App); return ( - - - - - Minimal Todo App (Evolu + Expo) - - - {/* - Suspense delivers great UX (no loading flickers) and DX (no loading - states to manage). Highly recommended with Evolu. - */} - - - - - - - - - + + + {run.deps.random.next()} - {app.appOwner.id} + + ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#f9fafb", - }, - scrollView: { - flex: 1, - }, - contentContainer: { - flexGrow: 1, - paddingHorizontal: 32, - paddingVertical: 32, - }, - maxWidthContainer: { - maxWidth: 400, - alignSelf: "center", - width: "100%", - }, - header: { - marginBottom: 8, - paddingBottom: 16, - alignItems: "center", - }, - title: { - fontSize: 20, - fontWeight: "600", - color: "#111827", - textAlign: "center", - }, - todosContainer: { - backgroundColor: "#ffffff", - borderRadius: 8, - paddingHorizontal: 24, - paddingVertical: 24, - paddingBottom: 24, - shadowColor: "#000", - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, - shadowRadius: 3, - elevation: 1, - borderWidth: 1, - borderColor: "#e5e7eb", - }, - todosList: { - marginBottom: 24, - }, - todoItem: { - flexDirection: "row", - alignItems: "center", - paddingVertical: 8, - paddingHorizontal: -8, - marginHorizontal: -8, - }, - todoCheckbox: { - flexDirection: "row", - alignItems: "center", - flex: 1, - }, - checkbox: { - width: 16, - height: 16, - borderRadius: 2, - borderWidth: 1, - borderColor: "#d1d5db", - backgroundColor: "#ffffff", - marginRight: 12, - alignItems: "center", - justifyContent: "center", - }, - checkboxChecked: { - backgroundColor: "#3b82f6", - borderColor: "#3b82f6", - }, - checkmark: { - color: "#ffffff", - fontSize: 10, - fontWeight: "bold", - }, - todoTitle: { - fontSize: 14, - color: "#111827", - flex: 1, - }, - todoTitleCompleted: { - color: "#6b7280", - textDecorationLine: "line-through", - }, - todoActions: { - flexDirection: "row", - gap: 4, - }, - actionButton: { - padding: 4, - }, - editIcon: { - fontSize: 16, - }, - deleteIcon: { - fontSize: 16, - }, - addTodoContainer: { - flexDirection: "row", - gap: 8, - marginHorizontal: -8, - }, - textInput: { - flex: 1, - borderRadius: 6, - backgroundColor: "#ffffff", - paddingHorizontal: 12, - paddingVertical: 6, - fontSize: 16, - color: "#111827", - borderWidth: 1, - borderColor: "#d1d5db", - }, - button: { - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 8, - alignItems: "center", - justifyContent: "center", - }, - buttonPrimary: { - backgroundColor: "#3b82f6", - }, - buttonSecondary: { - backgroundColor: "#f3f4f6", - }, - buttonText: { - fontSize: 14, - fontWeight: "500", - }, - buttonTextPrimary: { - color: "#ffffff", - }, - buttonTextSecondary: { - color: "#374151", - }, - ownerActionsContainer: { - marginTop: 32, - backgroundColor: "#ffffff", - borderRadius: 8, - padding: 24, - paddingTop: 18, - shadowColor: "#000", - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, - shadowRadius: 3, - elevation: 1, - borderWidth: 1, - borderColor: "#e5e7eb", - }, - sectionTitle: { - fontSize: 18, - fontWeight: "500", - color: "#111827", - marginBottom: 16, - }, - sectionDescription: { - fontSize: 14, - color: "#6b7280", - marginBottom: 16, - lineHeight: 20, - }, - ownerActionsButtons: { - gap: 12, - }, - fullWidthButton: { - width: "100%", - }, - mnemonicContainer: { - backgroundColor: "#f9fafb", - padding: 12, - borderRadius: 6, - }, - mnemonicLabel: { - fontSize: 12, - fontWeight: "500", - color: "#374151", - marginBottom: 8, - }, - mnemonicTextArea: { - backgroundColor: "#ffffff", - borderBottomWidth: 1, - borderBottomColor: "#d1d5db", - paddingHorizontal: 8, - paddingVertical: 4, - fontFamily: "monospace", - fontSize: 12, - color: "#111827", - }, - actionButtonsRow: { - flexDirection: "row", - gap: 8, - }, - flexButton: { - flex: 1, - }, - authActionsContainer: { - marginTop: 32, - backgroundColor: "#ffffff", - borderRadius: 8, - padding: 24, - paddingTop: 18, - shadowColor: "#000", - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, - shadowRadius: 3, - elevation: 1, - borderWidth: 1, - borderColor: "#e5e7eb", - }, - ownerProfileContainer: { - marginBottom: 16, - }, - ownerProfileRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingVertical: 12, - paddingHorizontal: 12, - backgroundColor: "#f9fafb", - borderRadius: 6, - marginBottom: 8, - }, - ownerInfo: { - flexDirection: "row", - alignItems: "center", - flex: 1, - gap: 8, - marginRight: 12, - }, - ownerDetails: { - flex: 1, - }, - ownerUsername: { - fontSize: 14, - fontWeight: "500", - color: "#111827", - marginBottom: 2, - }, - ownerIdText: { - fontSize: 10, - color: "#6b7280", - fontStyle: "italic", - }, - loginButton: { - paddingHorizontal: 16, - }, - otherOwnersContainer: { - marginTop: 16, - }, -}); - -/** - * Formats Evolu Type errors into user-friendly messages. - * - * Evolu Type typed errors ensure every error type used in schema must have a - * formatter. TypeScript enforces this at compile-time, preventing unhandled - * validation errors from reaching users. - * - * The `createFormatTypeError` function handles both built-in and custom errors, - * and lets us override default formatting for specific errors. - */ -const formatTypeError = Evolu.createFormatTypeError< - Evolu.MinLengthError | Evolu.MaxLengthError ->((error): string => { - switch (error.type) { - case "MinLength": - return `Text must be at least ${error.min} character${error.min === 1 ? "" : "s"} long`; - case "MaxLength": - return `Text is too long (maximum ${error.max} characters)`; - } -}); +// import * as Evolu from "@evolu/common"; +// import { createEvoluContext } from "@evolu/react"; +// import { createRun } from "@evolu/react-native"; +// import { evoluReactNativeDeps } from "@evolu/react-native/expo-sqlite"; +// import { Suspense, use } from "react"; +// import { Text, View } from "react-native"; +// +// // Primary keys are branded types, preventing accidental use of IDs across +// // different tables (e.g., a TodoId can't be used where a UserId is expected). +// const TodoId = Evolu.id("Todo"); +// type TodoId = typeof TodoId.Type; +// +// // Schema defines database structure with runtime validation. +// // Column types validate data on insert/update/upsert/sync. +// const Schema = { +// todo: { +// id: TodoId, +// // Branded type ensuring titles are non-empty and ≤100 chars. +// title: Evolu.NonEmptyString100, +// // SQLite doesn't support the boolean type; it uses 0 and 1 instead. +// isCompleted: Evolu.nullOr(Evolu.SqliteBoolean), +// }, +// }; +// +// // Create Run with Evolu dependencies for React Native. +// const run = createRun(evoluReactNativeDeps); +// +// // Create Evolu App. +// const app = run( +// Evolu.createEvolu(Schema, { +// name: Evolu.SimpleName.orThrow("rn-expo"), +// }), +// ); +// +// const [App, AppProvider] = createEvoluContext(app); +// +// export default function Index() { +// return ( +// +// +// +// +// +// ); +// } +// +// const Test = () => { +// const app = use(App); +// +// return ( +// +// +// {run.deps.random.next()} - {app.appOwner.id} +// +// +// ); +// }; +// +// // import Alert from "@blazejkustra/react-native-alert"; +// // import * as Evolu from "@evolu/common"; +// // import { createUseEvolu, EvoluProvider, useQuery } from "@evolu/react"; +// // import { EvoluIdenticon } from "@evolu/react-native"; +// // import { +// // evoluReactNativeDeps, +// // localAuth, +// // } from "@evolu/react-native/expo-sqlite"; +// // import { FC, Suspense, use, useEffect, useMemo, useState } from "react"; +// // import { +// // ActivityIndicator, +// // ScrollView, +// // StyleSheet, +// // Text, +// // TextInput, +// // TouchableOpacity, +// // View, +// // } from "react-native"; +// // import { SafeAreaView } from "react-native-safe-area-context"; +// // +// // // Namespace for the current app (scopes databases, passkeys, etc.) +// // const service = "rn-expo"; +// // +// // // Primary keys are branded types, preventing accidental use of IDs across +// // // different tables (e.g., a TodoId can't be used where a UserId is expected). +// // const TodoId = Evolu.id("Todo"); +// // type TodoId = typeof TodoId.Type; +// // +// // // Schema defines database structure with runtime validation. +// // // Column types validate data on insert/update/upsert. +// // const Schema = { +// // todo: { +// // id: TodoId, +// // // Branded type ensuring titles are non-empty and ≤100 chars. +// // title: Evolu.NonEmptyString100, +// // // SQLite doesn't support the boolean type; it uses 0 and 1 instead. +// // isCompleted: Evolu.nullOr(Evolu.SqliteBoolean), +// // }, +// // }; +// // +// // export default function Index(): React.ReactNode { +// // const [authResult, setAuthResult] = useState(null); +// // const [ownerIds, setOwnerIds] = useState | null>(null); +// // const [evolu, setEvolu] = useState | null>(null); +// // +// // useEffect(() => { +// // (async () => { +// // const authResult = await localAuth.getOwner({ service }); +// // const ownerIds = await localAuth.getProfiles({ service }); +// // const evolu = Evolu.createEvolu(evoluReactNativeDeps)(Schema, { +// // name: Evolu.SimpleName.orThrow( +// // `${service}-${authResult?.owner?.id ?? "guest"}`, +// // ), +// // encryptionKey: authResult?.owner?.encryptionKey, +// // externalAppOwner: authResult?.owner, +// // // ...(process.env.NODE_ENV === "development" && { +// // // transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], +// // // }), +// // }); +// // +// // setEvolu(evolu as Evolu.Evolu); +// // setOwnerIds(ownerIds); +// // setAuthResult(authResult); +// // +// // /** +// // * Subscribe to unexpected Evolu errors (database, network, sync issues). +// // * These should not happen in normal operation, so always log them for +// // * debugging. Show users a friendly error message instead of technical +// // * details. +// // */ +// // return evolu.subscribeError(() => { +// // const error = evolu.getError(); +// // if (!error) return; +// // Alert.alert("🚨 Evolu error occurred! Check the console."); +// // // eslint-disable-next-line no-console +// // console.error(error); +// // }); +// // })().catch((error) => { +// // console.error(error); +// // }); +// // }, []); +// // +// // if (evolu == null) { +// // return ( +// // +// // +// // +// // ); +// // } +// // +// // return ( +// // +// // +// // +// // ); +// // } +// // +// // const EvoluDemo = ({ +// // evolu, +// // ownerIds, +// // authResult, +// // }: { +// // evolu: Evolu.Evolu; +// // ownerIds: Array | null; +// // authResult: Evolu.AuthResult | null; +// // }): React.ReactNode => { +// // const useEvolu = createUseEvolu(evolu); +// // +// // // Evolu uses Kysely for type-safe SQL (https://kysely.dev/). +// // const todosQuery = evolu.createQuery((db) => +// // db +// // // Type-safe SQL: try autocomplete for table and column names. +// // .selectFrom("todo") +// // .select(["id", "title", "isCompleted"]) +// // // Soft delete: filter out deleted rows. +// // .where("isDeleted", "is not", Evolu.sqliteTrue) +// // // Like with GraphQL, all columns except id are nullable in queries +// // // (even if defined without nullOr in the schema) to allow schema +// // // evolution without migrations. Filter nulls with where + $narrowType. +// // .where("title", "is not", null) +// // .$narrowType<{ title: Evolu.kysely.NotNull }>() +// // // Columns createdAt, updatedAt, isDeleted are auto-added to all tables. +// // .orderBy("createdAt"), +// // ); +// // +// // // Extract the row type from the query for type-safe component props. +// // type TodosRow = typeof todosQuery.Row; +// // +// // const Todos: FC = () => { +// // // useQuery returns live data - component re-renders when data changes. +// // const todos = useQuery(todosQuery); +// // const { insert } = useEvolu(); +// // const [newTodoTitle, setNewTodoTitle] = useState(""); +// // +// // const handleAddTodo = () => { +// // const result = insert( +// // "todo", +// // { title: newTodoTitle.trim() }, +// // { +// // onComplete: () => { +// // setNewTodoTitle(""); +// // }, +// // }, +// // ); +// // +// // if (!result.ok) { +// // Alert.alert("Error", formatTypeError(result.error)); +// // } +// // }; +// // +// // return ( +// // 0 ? 6 : 24 }, +// // ]} +// // > +// // 0 ? "flex" : "none" }, +// // ]} +// // > +// // {todos.map((todo) => ( +// // +// // ))} +// // +// // +// // +// // +// // +// // +// // +// // ); +// // }; +// // +// // const TodoItem: FC<{ +// // row: TodosRow; +// // }> = ({ row: { id, title, isCompleted } }) => { +// // const { update } = useEvolu(); +// // +// // const handleToggleCompletedPress = () => { +// // update("todo", { +// // id, +// // isCompleted: Evolu.booleanToSqliteBoolean(!isCompleted), +// // }); +// // }; +// // +// // const handleRenamePress = () => { +// // Alert.prompt( +// // "Edit Todo", +// // "Enter new title:", +// // [ +// // { text: "Cancel", style: "cancel" }, +// // { +// // text: "Save", +// // onPress: (newTitle?: string) => { +// // if (newTitle != null && newTitle.trim()) { +// // const result = update("todo", { id, title: newTitle.trim() }); +// // if (!result.ok) { +// // Alert.alert("Error", formatTypeError(result.error)); +// // } +// // } +// // }, +// // }, +// // ], +// // "plain-text", +// // title, +// // ); +// // }; +// // +// // const handleDeletePress = () => { +// // update("todo", { +// // id, +// // // Soft delete with isDeleted flag (CRDT-friendly, preserves sync history). +// // isDeleted: Evolu.sqliteTrue, +// // }); +// // }; +// // +// // return ( +// // +// // +// // +// // +// // ✓ +// // +// // +// // +// // {title} +// // +// // +// // +// // +// // +// // ✏️ +// // +// // +// // 🗑️ +// // +// // +// // +// // ); +// // }; +// // +// // const OwnerActions: FC = () => { +// // const evolu = useEvolu(); +// // const appOwner = use(evolu.appOwner); +// // const [showMnemonic, setShowMnemonic] = useState(false); +// // +// // // Restore owner from mnemonic to sync data across devices. +// // const handleRestoreAppOwnerPress = () => { +// // Alert.prompt( +// // "Restore Account", +// // "Enter your mnemonic to restore your data:", +// // [ +// // { text: "Cancel", style: "cancel" }, +// // { +// // text: "Restore", +// // onPress: (mnemonic?: string) => { +// // if (mnemonic == null) return; +// // +// // const result = Evolu.Mnemonic.from(mnemonic.trim()); +// // if (!result.ok) { +// // Alert.alert("Error", formatTypeError(result.error)); +// // return; +// // } +// // +// // void evolu.restoreAppOwner(result.value); +// // }, +// // }, +// // ], +// // "plain-text", +// // ); +// // }; +// // +// // const handleResetAppOwnerPress = () => { +// // Alert.alert( +// // "Reset All Data", +// // "Are you sure? This will delete all your local data.", +// // [ +// // { text: "Cancel", style: "cancel" }, +// // { +// // text: "Reset", +// // style: "destructive", +// // onPress: () => { +// // void evolu.resetAppOwner(); +// // }, +// // }, +// // ], +// // ); +// // }; +// // +// // return ( +// // +// // Account +// // {appOwner && ( +// // +// // +// // +// // )} +// // +// // Todos are stored in local SQLite. When you sync across devices, your +// // data is end-to-end encrypted using your mnemonic. +// // +// // +// // +// // { +// // setShowMnemonic(!showMnemonic); +// // }} +// // style={styles.fullWidthButton} +// // /> +// // +// // {showMnemonic && appOwner?.mnemonic && ( +// // +// // +// // Your Mnemonic (keep this safe!) +// // +// // +// // +// // )} +// // +// // +// // +// // +// // +// // +// // +// // ); +// // }; +// // +// // const AuthActions: FC = () => { +// // const appOwner = use(evolu.appOwner); +// // const otherOwnerIds = useMemo( +// // () => ownerIds?.filter(({ ownerId }) => ownerId !== appOwner?.id) ?? [], +// // [appOwner?.id, ownerIds], +// // ); +// // +// // // Create a new owner and register it to a passkey. +// // const handleRegisterPress = async () => { +// // Alert.prompt( +// // "Register Passkey", +// // "Enter your username:", +// // [ +// // { text: "Cancel", style: "cancel" }, +// // { +// // text: "Register", +// // onPress: async (username?: string) => { +// // if (username == null) return; +// // +// // // Determine if this is a guest login or a new owner. +// // const isGuest = !Boolean(authResult?.owner); +// // +// // // Register the guest owner or create a new one if this is already registered. +// // const mnemonic = isGuest ? appOwner?.mnemonic : undefined; +// // const result = await localAuth.register(username, { +// // service, +// // mnemonic, +// // }); +// // if (result) { +// // // If this is a guest owner, we should clear the database and reload. +// // // The owner is transferred to a new database on next login. +// // if (isGuest) { +// // evolu.resetAppOwner({ reload: true }); +// // // Otherwise, just reload the app (in RN, we can't reload like web) +// // } else { +// // evolu.reloadApp(); +// // } +// // } else { +// // Alert.alert("Error", "Failed to register profile"); +// // } +// // }, +// // }, +// // ], +// // "plain-text", +// // ); +// // }; +// // +// // // Login with a specific owner id using the registered passkey. +// // const handleLoginPress = async (ownerId: Evolu.OwnerId) => { +// // const result = await localAuth.login(ownerId, { service }); +// // if (result) { +// // evolu.reloadApp(); +// // } else { +// // Alert.alert("Error", "Failed to login"); +// // } +// // }; +// // +// // // Clear all data including passkeys and metadata. +// // const handleClearAllPress = async () => { +// // Alert.alert( +// // "Clear All Data", +// // "Are you sure you want to clear all data? This will remove all passkeys and cannot be undone.", +// // [ +// // { text: "Cancel", style: "cancel" }, +// // { +// // text: "Clear", +// // style: "destructive", +// // onPress: async () => { +// // await localAuth.clearAll({ service }); +// // void evolu.resetAppOwner({ reload: true }); +// // }, +// // }, +// // ], +// // ); +// // }; +// // +// // return ( +// // +// // Passkeys +// // +// // Register a new passkey or choose a previously registered one. +// // +// // +// // +// // +// // +// // {otherOwnerIds.length > 0 && ( +// // +// // {otherOwnerIds.map(({ ownerId, username }) => ( +// // +// // ))} +// // +// // )} +// // +// // ); +// // }; +// // +// // const OwnerProfile: FC<{ +// // ownerId: Evolu.OwnerId; +// // username: string; +// // handleLoginPress?: (ownerId: Evolu.OwnerId) => void; +// // }> = ({ ownerId, username, handleLoginPress }) => { +// // return ( +// // +// // +// // +// // +// // {username} +// // +// // {ownerId as string} +// // +// // +// // +// // {handleLoginPress && ( +// // handleLoginPress(ownerId)} +// // style={styles.loginButton} +// // /> +// // )} +// // +// // ); +// // }; +// // +// // const CustomButton: FC<{ +// // title: string; +// // style?: any; +// // onPress: () => void; +// // variant?: "primary" | "secondary"; +// // }> = ({ title, style, onPress, variant = "secondary" }) => { +// // const buttonStyle = [ +// // styles.button, +// // variant === "primary" ? styles.buttonPrimary : styles.buttonSecondary, +// // style, +// // ]; +// // +// // const textStyle = [ +// // styles.buttonText, +// // variant === "primary" +// // ? styles.buttonTextPrimary +// // : styles.buttonTextSecondary, +// // ]; +// // +// // return ( +// // +// // {title} +// // +// // ); +// // }; +// // +// // return ( +// // +// // +// // +// // +// // Minimal Todo App (Evolu + Expo) +// // +// // +// // {/* +// // Suspense delivers great UX (no loading flickers) and DX (no loading +// // states to manage). Highly recommended with Evolu. +// // */} +// // +// // +// // +// // +// // +// // +// // +// // +// // +// // ); +// // }; +// // +// // const styles = StyleSheet.create({ +// // container: { +// // flex: 1, +// // backgroundColor: "#f9fafb", +// // }, +// // scrollView: { +// // flex: 1, +// // }, +// // contentContainer: { +// // flexGrow: 1, +// // paddingHorizontal: 32, +// // paddingVertical: 32, +// // }, +// // maxWidthContainer: { +// // maxWidth: 400, +// // alignSelf: "center", +// // width: "100%", +// // }, +// // header: { +// // marginBottom: 8, +// // paddingBottom: 16, +// // alignItems: "center", +// // }, +// // title: { +// // fontSize: 20, +// // fontWeight: "600", +// // color: "#111827", +// // textAlign: "center", +// // }, +// // todosContainer: { +// // backgroundColor: "#ffffff", +// // borderRadius: 8, +// // paddingHorizontal: 24, +// // paddingVertical: 24, +// // paddingBottom: 24, +// // shadowColor: "#000", +// // shadowOffset: { width: 0, height: 1 }, +// // shadowOpacity: 0.05, +// // shadowRadius: 3, +// // elevation: 1, +// // borderWidth: 1, +// // borderColor: "#e5e7eb", +// // }, +// // todosList: { +// // marginBottom: 24, +// // }, +// // todoItem: { +// // flexDirection: "row", +// // alignItems: "center", +// // paddingVertical: 8, +// // paddingHorizontal: -8, +// // marginHorizontal: -8, +// // }, +// // todoCheckbox: { +// // flexDirection: "row", +// // alignItems: "center", +// // flex: 1, +// // }, +// // checkbox: { +// // width: 16, +// // height: 16, +// // borderRadius: 2, +// // borderWidth: 1, +// // borderColor: "#d1d5db", +// // backgroundColor: "#ffffff", +// // marginRight: 12, +// // alignItems: "center", +// // justifyContent: "center", +// // }, +// // checkboxChecked: { +// // backgroundColor: "#3b82f6", +// // borderColor: "#3b82f6", +// // }, +// // checkmark: { +// // color: "#ffffff", +// // fontSize: 10, +// // fontWeight: "bold", +// // }, +// // todoTitle: { +// // fontSize: 14, +// // color: "#111827", +// // flex: 1, +// // }, +// // todoTitleCompleted: { +// // color: "#6b7280", +// // textDecorationLine: "line-through", +// // }, +// // todoActions: { +// // flexDirection: "row", +// // gap: 4, +// // }, +// // actionButton: { +// // padding: 4, +// // }, +// // editIcon: { +// // fontSize: 16, +// // }, +// // deleteIcon: { +// // fontSize: 16, +// // }, +// // addTodoContainer: { +// // flexDirection: "row", +// // gap: 8, +// // marginHorizontal: -8, +// // }, +// // textInput: { +// // flex: 1, +// // borderRadius: 6, +// // backgroundColor: "#ffffff", +// // paddingHorizontal: 12, +// // paddingVertical: 6, +// // fontSize: 16, +// // color: "#111827", +// // borderWidth: 1, +// // borderColor: "#d1d5db", +// // }, +// // button: { +// // paddingHorizontal: 12, +// // paddingVertical: 8, +// // borderRadius: 8, +// // alignItems: "center", +// // justifyContent: "center", +// // }, +// // buttonPrimary: { +// // backgroundColor: "#3b82f6", +// // }, +// // buttonSecondary: { +// // backgroundColor: "#f3f4f6", +// // }, +// // buttonText: { +// // fontSize: 14, +// // fontWeight: "500", +// // }, +// // buttonTextPrimary: { +// // color: "#ffffff", +// // }, +// // buttonTextSecondary: { +// // color: "#374151", +// // }, +// // ownerActionsContainer: { +// // marginTop: 32, +// // backgroundColor: "#ffffff", +// // borderRadius: 8, +// // padding: 24, +// // paddingTop: 18, +// // shadowColor: "#000", +// // shadowOffset: { width: 0, height: 1 }, +// // shadowOpacity: 0.05, +// // shadowRadius: 3, +// // elevation: 1, +// // borderWidth: 1, +// // borderColor: "#e5e7eb", +// // }, +// // sectionTitle: { +// // fontSize: 18, +// // fontWeight: "500", +// // color: "#111827", +// // marginBottom: 16, +// // }, +// // sectionDescription: { +// // fontSize: 14, +// // color: "#6b7280", +// // marginBottom: 16, +// // lineHeight: 20, +// // }, +// // ownerActionsButtons: { +// // gap: 12, +// // }, +// // fullWidthButton: { +// // width: "100%", +// // }, +// // mnemonicContainer: { +// // backgroundColor: "#f9fafb", +// // padding: 12, +// // borderRadius: 6, +// // }, +// // mnemonicLabel: { +// // fontSize: 12, +// // fontWeight: "500", +// // color: "#374151", +// // marginBottom: 8, +// // }, +// // mnemonicTextArea: { +// // backgroundColor: "#ffffff", +// // borderBottomWidth: 1, +// // borderBottomColor: "#d1d5db", +// // paddingHorizontal: 8, +// // paddingVertical: 4, +// // fontFamily: "monospace", +// // fontSize: 12, +// // color: "#111827", +// // }, +// // actionButtonsRow: { +// // flexDirection: "row", +// // gap: 8, +// // }, +// // flexButton: { +// // flex: 1, +// // }, +// // authActionsContainer: { +// // marginTop: 32, +// // backgroundColor: "#ffffff", +// // borderRadius: 8, +// // padding: 24, +// // paddingTop: 18, +// // shadowColor: "#000", +// // shadowOffset: { width: 0, height: 1 }, +// // shadowOpacity: 0.05, +// // shadowRadius: 3, +// // elevation: 1, +// // borderWidth: 1, +// // borderColor: "#e5e7eb", +// // }, +// // ownerProfileContainer: { +// // marginBottom: 16, +// // }, +// // ownerProfileRow: { +// // flexDirection: "row", +// // justifyContent: "space-between", +// // alignItems: "center", +// // paddingVertical: 12, +// // paddingHorizontal: 12, +// // backgroundColor: "#f9fafb", +// // borderRadius: 6, +// // marginBottom: 8, +// // }, +// // ownerInfo: { +// // flexDirection: "row", +// // alignItems: "center", +// // flex: 1, +// // gap: 8, +// // marginRight: 12, +// // }, +// // ownerDetails: { +// // flex: 1, +// // }, +// // ownerUsername: { +// // fontSize: 14, +// // fontWeight: "500", +// // color: "#111827", +// // marginBottom: 2, +// // }, +// // ownerIdText: { +// // fontSize: 10, +// // color: "#6b7280", +// // fontStyle: "italic", +// // }, +// // loginButton: { +// // paddingHorizontal: 16, +// // }, +// // otherOwnersContainer: { +// // marginTop: 16, +// // }, +// // }); +// // +// // /** +// // * Formats Evolu Type errors into user-friendly messages. +// // * +// // * Evolu Type typed errors ensure every error type used in schema must have a +// // * formatter. TypeScript enforces this at compile-time, preventing unhandled +// // * validation errors from reaching users. +// // * +// // * The `createFormatTypeError` function handles both built-in and custom errors, +// // * and lets us override default formatting for specific errors. +// // */ +// // const formatTypeError = Evolu.createFormatTypeError< +// // Evolu.MinLengthError | Evolu.MaxLengthError +// // >((error): string => { +// // switch (error.type) { +// // case "MinLength": +// // return `Text must be at least ${error.min} character${error.min === 1 ? "" : "s"} long`; +// // case "MaxLength": +// // return `Text is too long (maximum ${error.max} characters)`; +// // } +// // }); From f745401b2202bd1d1774b9b7506943d6140da54e Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 6 Feb 2026 12:01:25 +0100 Subject: [PATCH 25/37] Stub EvoluFullExample; comment out implementation (cherry picked from commit 5b7f2f9c344b5ed04e6cf220c6d60d3589860dd8) --- .../playgrounds/full/EvoluFullExample.tsx | 1854 ++++++++--------- 1 file changed, 927 insertions(+), 927 deletions(-) diff --git a/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx b/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx index edd8ec328..e78636a39 100644 --- a/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx +++ b/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx @@ -1,929 +1,929 @@ "use client"; -import { - booleanToSqliteBoolean, - createEvolu, - createFormatTypeError, - createObjectURL, - createQueryBuilder, - FiniteNumber, - id, - idToIdBytes, - json, - kysely, - type MaxLengthError, - type MinLengthError, - Mnemonic, - maxLength, - NonEmptyString, - NonEmptyTrimmedString100, - nullOr, - object, - SimpleName, - SqliteBoolean, - sqliteFalse, - sqliteTrue, - timestampBytesToTimestamp, -} from "@evolu/common"; -import { timestampToDateIso } from "@evolu/common/local-first"; -import { - createUseEvolu, - EvoluProvider, - useQueries, - useQuery, -} from "@evolu/react"; -import { createEvoluDeps } from "@evolu/react-web"; -import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; -import { - IconChecklist, - IconEdit, - IconHistory, - IconRestore, - IconTrash, -} from "@tabler/icons-react"; -import clsx from "clsx"; -import { - type FC, - type KeyboardEvent, - Suspense, - startTransition, - use, - useState, -} from "react"; - -// TODO: Epochs and sharing. - -const ProjectId = id("Project"); -type ProjectId = typeof ProjectId.Type; - -const TodoId = id("Todo"); -// biome-ignore lint/correctness/noUnusedVariables: Context -type TodoId = typeof TodoId.Type; - -// A custom branded Type. -const NonEmptyString50 = maxLength(50)(NonEmptyString); -// string & Brand<"MinLength1"> & Brand<"MaxLength50"> -// biome-ignore lint/correctness/noUnusedVariables: Context -type NonEmptyString50 = typeof NonEmptyString50.Type; - -// SQLite supports JSON values. -// Use JSON for semi-structured data like API responses, external integrations, -// or when the schema varies by use case. -// Let's create an object to demonstrate it. -const Foo = object({ - foo: NonEmptyString50, - // Did you know that JSON.stringify converts NaN (a number) into null? - // To prevent this, use FiniteNumber. - bar: FiniteNumber, -}); -// biome-ignore lint/correctness/noUnusedVariables: Context -type Foo = typeof Foo.Type; - -// SQLite stores JSON values as strings. Evolu provides a convenient `json` -// Type Factory for type-safe JSON serialization and parsing. -const [FooJson, fooToFooJson, fooJsonToFoo] = json(Foo, "FooJson"); -// string & Brand<"FooJson"> -// biome-ignore lint/correctness/noUnusedVariables: Context -type FooJson = typeof FooJson.Type; - -const Schema = { - project: { - id: ProjectId, - name: NonEmptyTrimmedString100, - fooJson: FooJson, - }, - todo: { - id: TodoId, - title: NonEmptyTrimmedString100, - isCompleted: nullOr(SqliteBoolean), - projectId: nullOr(ProjectId), - }, -}; - -const createQuery = createQueryBuilder(Schema); - -const deps = createEvoluDeps(); - -const evolu = createEvolu(deps)(Schema, { - name: SimpleName.orThrow("full-example"), - - // reloadUrl: "/playgrounds/full", - - ...(process.env.NODE_ENV === "development" && { - transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], - - // Empty transports for local-only instance. - // transports: [], - }), - - // https://www.evolu.dev/docs/indexes - indexes: (create) => [ - create("todoCreatedAt").on("todo").column("createdAt"), - create("projectCreatedAt").on("project").column("createdAt"), - create("todoProjectId").on("todo").column("projectId"), - ], - - // enableLogging: false, -}); - -const useEvolu = createUseEvolu(evolu); - -evolu.subscribeError(() => { - const error = evolu.getError(); - if (!error) return; - - alert("🚨 Evolu error occurred! Check the console."); - // eslint-disable-next-line no-console - console.error(error); -}); - -export const EvoluFullExample: FC = () => ( -
-
- - - - - -
-
-); - -const App: FC = () => { - const [activeTab, setActiveTab] = useState< - "home" | "projects" | "account" | "trash" - >("home"); - - const createHandleTabClick = (tab: typeof activeTab) => () => { - // startTransition prevents UI flickers when switching tabs by keeping - // the current view visible while Suspense prepares the next one - // Test: Remove startTransition, add a todo, delete it, click to Trash. - // You will see a visible blink without startTransition. - startTransition(() => { - setActiveTab(tab); - }); - }; - - return ( -
-
-
- - - - -
-
- - {activeTab === "home" && } - {activeTab === "projects" && } - {activeTab === "account" && } - {activeTab === "trash" && } -
- ); -}; - -const projectsWithTodosQuery = createQuery( - (db) => - db - .selectFrom("project") - .select(["id", "name"]) - // https://kysely.dev/docs/recipes/relations - .select((eb) => [ - kysely - .jsonArrayFrom( - eb - .selectFrom("todo") - .select([ - "todo.id", - "todo.title", - "todo.isCompleted", - "todo.projectId", - ]) - .whereRef("todo.projectId", "=", "project.id") - .where("todo.isDeleted", "is not", sqliteTrue) - .where("todo.title", "is not", null) - .$narrowType<{ title: kysely.NotNull }>() - .orderBy("createdAt"), - ) - .as("todos"), - ]) - .where("project.isDeleted", "is not", sqliteTrue) - .where("name", "is not", null) - .$narrowType<{ name: kysely.NotNull }>() - .orderBy("createdAt"), - { - // Log how long each query execution takes - logQueryExecutionTime: false, - - // Log the SQLite query execution plan for optimization analysis - logExplainQueryPlan: false, - }, -); - -type ProjectsWithTodosRow = typeof projectsWithTodosQuery.Row; - -const HomeTab: FC = () => { - const [projectsWithTodos, projects] = useQueries([ - projectsWithTodosQuery, - /** - * Load projects separately for better cache efficiency. Projects change - * less frequently than todos, preventing unnecessary re-renders. Multiple - * queries are fine in local-first - no network overhead. - */ - projectsQuery, - ]); - - const handleAddProjectClick = useAddProject(); - - if (projectsWithTodos.length === 0) { - return ( -
-
- -
-

- No projects yet -

-

- Create your first project to get started -

-
- ); - } - - return ( -
-
- {projectsWithTodos.map((project) => ( - - ))} -
-
- ); -}; - -const HomeTabProject: FC<{ - project: ProjectsWithTodosRow; - todos: ProjectsWithTodosRow["todos"]; - projects: ReadonlyArray; -}> = ({ project, todos, projects }) => { - const { insert } = useEvolu(); - const [newTodoTitle, setNewTodoTitle] = useState(""); - - const addTodo = () => { - const result = insert( - "todo", - { - title: newTodoTitle.trim(), - projectId: project.id, - }, - { - onComplete: () => { - setNewTodoTitle(""); - }, - }, - ); - - if (!result.ok) { - alert(formatTypeError(result.error)); - } - }; - - const handleKeyPress = (e: KeyboardEvent) => { - if (e.key === "Enter") { - addTodo(); - } - }; - - return ( -
-
-

- - {project.name} -

-
- - {todos.length > 0 && ( -
    - {todos.map((todo) => ( - - ))} -
- )} - -
- { - setNewTodoTitle(e.target.value); - }} - data-1p-ignore // ignore this input from 1password, ugly hack but works - onKeyDown={handleKeyPress} - placeholder="Add a new todo..." - className="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" - /> -
-
- ); -}; - -const HomeTabProjectSectionTodoItem: FC<{ - // [number] extracts the element type from the todos array - row: ProjectsWithTodosRow["todos"][number]; - projects: ReadonlyArray; -}> = ({ row: { id, title, isCompleted, projectId }, projects }) => { - const { update } = useEvolu(); - - const handleToggleCompletedClick = () => { - // No need to check result if a mutation can't fail. - update("todo", { - id, - isCompleted: booleanToSqliteBoolean(!isCompleted), - }); - }; - - const handleProjectChange = (newProjectId: ProjectId) => { - update("todo", { id, projectId: newProjectId }); - }; - - const handleRenameClick = () => { - const newTitle = window.prompt("Edit todo", title); - if (newTitle == null) return; - - const result = update("todo", { id, title: newTitle.trim() }); - if (!result.ok) { - alert(formatTypeError(result.error)); - } - }; - - const handleDeleteClick = () => { - update("todo", { id, isDeleted: sqliteTrue }); - }; - - // Demonstrate history tracking. Evolu automatically tracks all changes - // in the evolu_history table, making it easy to build audit logs or undo features. - const titleHistoryQuery = createQuery((db) => - db - .selectFrom("evolu_history") - .select(["value", "timestamp"]) - .where("table", "==", "todo") - .where("id", "==", idToIdBytes(id)) - .where("column", "==", "title") - // The value isn't typed; this is how we can cast it. - .$narrowType<{ value: (typeof Schema)["todo"]["title"]["Type"] }>() - .orderBy("timestamp", "desc"), - ); - - const handleHistoryClick = () => { - void evolu.loadQuery(titleHistoryQuery).then((rows) => { - const rowsWithTimestamp = rows.map((row) => ({ - value: row.value, - timestamp: timestampToDateIso(timestampBytesToTimestamp(row.timestamp)), - })); - alert(JSON.stringify(rowsWithTimestamp, null, 2)); - }); - }; - - return ( -
  • - -
    -
    - - - - - -
    - {projects.map((project) => ( - - - - ))} -
    -
    -
    - - - -
    -
    -
  • - ); -}; - -const projectsQuery = createQuery((db) => - db - .selectFrom("project") - .select(["id", "name", "fooJson"]) - .where("isDeleted", "is not", sqliteTrue) - .where("name", "is not", null) - .$narrowType<{ name: kysely.NotNull }>() - .where("fooJson", "is not", null) - .$narrowType<{ fooJson: kysely.NotNull }>() - .orderBy("createdAt"), -); - -type ProjectsRow = typeof projectsQuery.Row; - -const useAddProject = () => { - const { insert } = useEvolu(); - - return () => { - const name = window.prompt("What's the project name?"); - if (name == null) return; - - // Demonstrate JSON usage. - const foo = Foo.from({ foo: "baz", bar: 42 }); - if (!foo.ok) return; - - const result = insert("project", { - name: name.trim(), - fooJson: fooToFooJson(foo.value), - }); - if (!result.ok) { - alert(formatTypeError(result.error)); - } - }; -}; - -const ProjectsTab: FC = () => { - const projects = useQuery(projectsQuery); - const handleAddProjectClick = useAddProject(); - - return ( -
    -
    - {projects.map((project) => ( - - ))} -
    -
    -
    -
    - ); -}; - -const ProjectsTabProjectItem: FC<{ - project: ProjectsRow; -}> = ({ project }) => { - const { update } = useEvolu(); - - const handleRenameClick = () => { - const newName = window.prompt("Edit project name", project.name); - if (newName == null) return; - - const result = update("project", { id: project.id, name: newName.trim() }); - if (!result.ok) { - alert(formatTypeError(result.error)); - } - }; - - const handleDeleteClick = () => { - if (confirm(`Are you sure you want to delete project "${project.name}"?`)) { - /** - * In a classic centralized client-server app, we would fetch all todos - * for this project and delete them too. But that approach is wrong for - * distributed eventually consistent systems for two reasons: - * - * 1. Sync overhead scales with todo count (a project with 10k todos would - * generate 10k sync messages instead of just 1 for the project) - * 2. It wouldn't delete todos from other devices before they sync - * - * The correct approach for local-first systems: handle cascading logic in - * the UI layer. Queries filter out deleted projects, so their todos - * naturally become hidden. If a todo detail view is needed, it should - * check whether its parent project was deleted. - */ - update("project", { - id: project.id, - isDeleted: sqliteTrue, - }); - } - }; - - // Demonstrate JSON deserialization. Because FooJson is a branded type, - // we can safely deserialize without validation - TypeScript guarantees - // only validated JSON strings can have the FooJson brand. - const _foo = fooJsonToFoo(project.fooJson); - - return ( -
    -
    - -
    -

    {project.name}

    -
    -
    -
    - - -
    -
    - ); -}; - -const AccountTab: FC = () => { - const evolu = useEvolu(); - const appOwner = use(evolu.appOwner); - - const [showMnemonic, setShowMnemonic] = useState(false); - - const handleRestoreAppOwnerClick = () => { - const mnemonic = window.prompt("Enter your mnemonic to restore your data:"); - if (mnemonic == null) return; - - const result = Mnemonic.from(mnemonic.trim()); - if (!result.ok) { - alert(formatTypeError(result.error)); - return; - } - - // void evolu.restoreAppOwner(result.value); - }; - - const handleResetAppOwnerClick = () => { - if (confirm("Are you sure? This will delete all your local data.")) { - // void evolu.resetAppOwner(); - } - }; - - const handleDownloadDatabaseClick = () => { - void evolu.exportDatabase().then((data) => { - using objectUrl = createObjectURL( - new Blob([data], { type: "application/x-sqlite3" }), - ); - - const link = document.createElement("a"); - link.href = objectUrl.url; - link.download = `${evolu.name}.sqlite3`; - link.click(); - }); - }; - - return ( -
    -

    - Todos are stored in local SQLite. When you sync across devices, your - data is end-to-end encrypted using your mnemonic. -

    - -
    -