Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 99 additions & 99 deletions bun.lock

Large diffs are not rendered by default.

38 changes: 19 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,33 +49,33 @@
"@capacitor/haptics": "8.0.2",
"@capacitor/ios": "8.5.2",
"@dedalik/use-react": "1.1.2",
"@evolu/common": "8.10.0",
"@evolu/nodejs": "4.0.0",
"@evolu/react-web": "3.0.3",
"@evolu/common": "8.11.0",
"@evolu/nodejs": "4.1.0",
"@evolu/react-web": "3.1.0",
"@evolu/sqlite-wasm": "2.2.4",
"@evolu/web": "3.1.2",
"@evolu/web": "3.2.0",
"@noble/hashes": "2.4.0",
"@scure/bip32": "2.4.0",
"@scure/bip39": "2.4.0",
"@scure/btc-signer": "2.4.1",
"@sentry/react": "10.75.1",
"@sentry/react": "11.0.0",
"@sentry/vite-plugin": "5.4.0",
"@t3-oss/env-core": "0.13.11",
"@tailwindcss/vite": "4.3.3",
"@tanstack/react-query": "5.103.2",
"@tanstack/react-router": "1.170.38",
"@tanstack/react-query": "5.104.0",
"@tanstack/react-router": "1.170.39",
"@yudiel/react-qr-scanner": "2.6.0",
"better-sqlite3": "13.0.3",
"bysquare": "4.0.1",
"bysquare": "4.0.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cn": "0.3.2",
"cn": "0.4.0",
"commander": "15.0.0",
"date-fns": "4.4.0",
"jotai": "3.0.0",
"kysely": "0.29.6",
"lucide-react": "1.47.0",
"motion": "13.4.0",
"lucide-react": "1.48.0",
"motion": "13.4.4",
"qrcode.react": "4.2.0",
"react": "19.3.0",
"react-dom": "19.3.0",
Expand All @@ -93,26 +93,26 @@
"@biomejs/biome": "2.5.14",
"@capacitor/cli": "8.5.2",
"@playwright/test": "1.63.0",
"@remotion/bundler": "4.0.526",
"@remotion/cli": "4.0.526",
"@remotion/renderer": "4.0.526",
"@remotion/bundler": "4.0.529",
"@remotion/cli": "4.0.529",
"@remotion/renderer": "4.0.529",
"@standard-schema/spec": "1.1.0",
"@tanstack/router-plugin": "1.168.40",
"@types/better-sqlite3": "9.6.0",
"@types/node": "26.6.2",
"@types/node": "26.6.3",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-basic-ssl": "2.3.0",
"@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "5.0.1",
"remotion": "4.0.526",
"@vitest/coverage-v8": "5.0.2",
"remotion": "4.0.529",
"shadcn": "4.21.0",
"sharp": "0.35.4",
"swc-node": "1.0.0",
"typescript": "6.0.3",
"vite": "8.3.0",
"vite": "8.3.1",
"vite-plugin-pwa": "1.3.0",
"vitest": "5.0.1"
"vitest": "5.0.2"
},
"trustedDependencies": [
"@sentry/cli",
Expand Down
67 changes: 16 additions & 51 deletions src/core/evolu/cli-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
testCreateWebSocket,
} from "@evolu/common"
import {
createEvoluDeps,
type DbWorkerInit,
initSharedWorker,
type SharedWorkerInput,
Expand Down Expand Up @@ -97,47 +98,11 @@ const createSqliteDep = (
},
})

/**
* Evolu's tenant disposal reaches for its own run after that run is gone,
* surfacing as `{ type: "AbortError", reason: { defect: Error } }`.
*
* ponytail: matched on the message, since Evolu gives the defect no code of
* its own - narrow this to a typed check if one ever appears.
*/
const isDisposedObjectTeardownRace = (reported: unknown): boolean => {
const defect = (
reported as { readonly reason?: { readonly defect?: unknown } }
)?.reason?.defect

return (
defect instanceof Error &&
defect.message.includes("Cannot use a disposed object")
)
}

export const setupRunWithEvoluDeps = async (mode: "memory" | string) => {
await using disposer = new AsyncDisposableStack()

const consoleStoreOutput = createConsoleStoreOutput()
const console = createConsole({ level: "warn" })
// The default reportDefect rethrows after a microtask to crash loudly.
// Evolu's SharedWorker/tenant teardown can race benignly across this
// single-process, multi-root simulation when everything shuts down at
// once, so log instead of letting that crash the CLI or the test run
// (matching @evolu/nodejs's runMain convention).
//
// That one race is reported as a full object plus stack *after* a command
// has already printed its result, which reads exactly like the command
// crashed - it is worth one line instead. Everything else still gets
// dumped whole.
const reportDefect = (reported: unknown) => {
if (isDisposedObjectTeardownRace(reported)) {
console.warn("Ignored Evolu teardown race: disposed object.")
return
}

console.error(reported)
}

const run = disposer.use(
createRun({
Expand All @@ -148,7 +113,6 @@ export const setupRunWithEvoluDeps = async (mode: "memory" | string) => {
createMessagePort: createMessagePort,
createWebSocket: testCreateWebSocket({ throwOnCreate: true }),
lockManager: testCreateLockManager(),
reportDefect,
})
)

Expand All @@ -164,7 +128,6 @@ export const setupRunWithEvoluDeps = async (mode: "memory" | string) => {
createMessagePort,
lockManager: testCreateLockManager(),
createSqliteDriver: () => () => ok(driver),
reportDefect,
})
)

Expand All @@ -173,30 +136,32 @@ export const setupRunWithEvoluDeps = async (mode: "memory" | string) => {
workerRun(startDbWorker(self))
})

const sharedWorker = disposer.use(
createSharedWorker<SharedWorkerInput, SharedWorkerOutput>((self) => {
run(initSharedWorker(self))
})
)
sharedWorker.port.onMessage = (message) => {
createDbWorker().postMessage(message, [message.port])
}
sharedWorker.port.postMessage({
type: "AnnounceTabLeader",
consoleLevel: "debug",
// Keep the SharedWorker's resources, so they are disposed before `run`
// and `workerRun`. Left to run disposal, its tenants would be torn down
// after their own runs were already disposed.
const sharedWorkerInit = Promise.withResolvers<AsyncDisposableStack>()
const sharedWorker = createSharedWorker<
SharedWorkerInput,
SharedWorkerOutput
>((self) => {
sharedWorkerInit.resolve(run.ok(initSharedWorker(self)))
})
disposer.use(await sharedWorkerInit.promise)

const sqlite = disposer.use(
await workerRun.ok(createSqlite(Name.orThrow("test")))
)
const runWithEvoluDeps = disposer.use(
run.create({
// createEvoluDeps owns the shared worker and speaks its tab protocol
// (Waiting/Connected/DbWorkerInit, tab leader election).
const evoluDeps = disposer.use(
createEvoluDeps({
...run.deps,
createDbWorker,
reloadApp: constVoid,
sharedWorker,
})
)
const runWithEvoluDeps = disposer.use(run.create(evoluDeps))
const disposables = disposer.move()

return {
Expand Down
4 changes: 2 additions & 2 deletions src/features/settings/security/evolu-transport-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export function EvoluTransportCard({ accountId }: EvoluTransportCardProps) {
</CardHeader>
<CardContent>
<div className="flex flex-col gap-5">
<TransportToggleList accountId={accountId} />

<form
onSubmit={(event) => {
event.preventDefault()
Expand Down Expand Up @@ -123,8 +125,6 @@ export function EvoluTransportCard({ accountId }: EvoluTransportCardProps) {
</Button>
</div>
</form>

<TransportToggleList accountId={accountId} />
</div>
</CardContent>
<CardFooter>
Expand Down
90 changes: 90 additions & 0 deletions src/features/settings/security/transport-sync-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
createId,
createIdFromString,
Millis,
type OwnerId,
testAppOwner,
testCreateDeps,
testName,
} from "@evolu/common"
import type {
SyncRoute,
SyncState,
SyncTransport,
} from "@evolu/common/local-first"
import { describe, expect, test } from "vitest"

import { findRelaySyncState } from "@/features/settings/security/transport-sync-state.ts"

const deps = testCreateDeps()

const transport: SyncTransport = {
id: createId<"SyncTransport">(deps),
label: "wss://relay.example/room",
readyState: "open",
openedAt: null,
closedAt: null,
error: null,
}

const route: SyncRoute = {
transportId: transport.id,
complete: true,
completeAt: Millis.orThrow(1000),
lastSentAt: Millis.orThrow(900),
lastReceivedAt: Millis.orThrow(1000),
error: null,
}

const stateFor = (ownerId: OwnerId): SyncState => ({
transports: [transport],
tenants: [
{
name: testName,
refused: false,
owners: [
{
ownerId,
writable: true,
transportIds: [transport.id],
routes: [route],
},
],
},
],
})

describe("findRelaySyncState", () => {
test("matches the configured url, ignoring its query", () => {
const relay = findRelaySyncState(
stateFor(testAppOwner.id),
testAppOwner.id,
"wss://relay.example/room?ownerId=x"
)

expect(relay?.status).toBe("synced")
expect(relay?.transport.id).toBe(transport.id)
})

test("ignores another owner's relay and an unknown url", () => {
const otherOwnerId = createIdFromString<"OwnerId">("other")

expect(
findRelaySyncState(
stateFor(otherOwnerId),
testAppOwner.id,
transport.label
)
).toBeNull()
expect(
findRelaySyncState(
stateFor(testAppOwner.id),
testAppOwner.id,
"wss://other.example"
)
).toBeNull()
expect(
findRelaySyncState(null, testAppOwner.id, transport.label)
).toBeNull()
})
})
38 changes: 38 additions & 0 deletions src/features/settings/security/transport-sync-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { OwnerId } from "@evolu/common"
import {
type RelaySyncState,
type SyncState,
syncStateToOwnerSyncStates,
} from "@evolu/common/local-first"

/**
* Evolu labels a transport by its URL without the query, which carries the
* owner id, so a configured URL is compared the same way.
*/
const transportLabel = (url: string): string => {
const queryIndex = url.indexOf("?")
return queryIndex === -1 ? url : url.slice(0, queryIndex)
}

/**
* The relay state of `url` for `ownerId`, or null while the shared worker has
* not reported it — before it connects, or right after a transport change
* reopens the app database.
*/
export const findRelaySyncState = (
state: SyncState | null,
ownerId: OwnerId,
url: string
): RelaySyncState | null => {
if (state === null) return null

const label = transportLabel(url)

for (const owner of syncStateToOwnerSyncStates(state)) {
if (owner.ownerId !== ownerId) continue
const relay = owner.relays.find((relay) => relay.transport.label === label)
if (relay) return relay
}

return null
}
Loading
Loading