Skip to content

Sync common-v8 changes: Refactor APIs, improve resource handling, and update dependencies#70

Merged
miccy merged 24 commits intomainfrom
dev/sync-common-v8-2026-03-11
Mar 11, 2026
Merged

Sync common-v8 changes: Refactor APIs, improve resource handling, and update dependencies#70
miccy merged 24 commits intomainfrom
dev/sync-common-v8-2026-03-11

Conversation

@miccy
Copy link
Copy Markdown
Contributor

@miccy miccy commented Mar 11, 2026

This pull request includes several dependency updates across the project, a refactor of the Ref utility to provide a richer and more consistent API for mutable references, and documentation improvements for the Result module. Additionally, the unused Instances multiton utility has been removed. The most significant changes are grouped below.

Refactor and API Improvements

  • Refactored the Ref utility in packages/common/src/Ref.ts to provide a more expressive and consistent API for mutable references, including new methods like getAndSet, setAndGet, update, getAndUpdate, updateAndGet, and a new signature for modify. The documentation was also clarified and simplified. [1] [2]

Code and Dependency Cleanup

  • Removed the unused Instances multiton utility from packages/common/src/Instances.ts to simplify the codebase.
  • Minor code cleanup in packages/common/src/Number.ts to ensure proper use of branding with lessThanOrEqualTo.
  • Removed an unused import in packages/common/src/Result.ts.

Dependency Updates

  • Updated various dependencies in package.json, packages/common/package.json, and several example app package.json files (Angular, React, Svelte, Vue) to their latest patch or minor versions for improved compatibility and security. [1] [2] [3] [4] [5] [6] [7] [8]

Documentation and Style Improvements

  • Expanded and clarified the documentation in packages/common/src/Result.ts to explain when and how to use the Result type, the intended imperative coding style, and guidance on error handling boundaries. [1] [2] [3] [4] [5] [6]

Minor Code Fixes

  • Updated code in several example apps to use Evolu.KyselyNotNull instead of the outdated Evolu.kysely.NotNull for type narrowing. [1] [2] [3] [4]
  • Swapped usage of getOk for getOrThrow in apps/relay/src/startBunRelay.ts for more explicit error handling. [1] [2]

Summary by CodeRabbit

  • Nové funkce

    • Rozšířené Store: nové metody getAndSet, setAndGet, update, getAndUpdate, updateAndGet, modify.
    • Nové JSON dotazovací helpery a reexporty pro pohodlnou práci s JSON v dotazech.
    • Testovací pomocník pro WebSocket.
  • Vylepšení

    • Asynchronní a referenčně počítaná správa zdrojů s legacy cestou.
    • Přesnější a konzistentní typování a pojmenování v knihovně (Runner → Run).
  • Změny API

    • Rozšířené rozhraní Ref s novými operacemi a upravenými návratovými hodnotami.
    • Odstraněné/konzolidované starší utilitky a exporty (migrace povahy veřejného rozhraní).

miccy added 23 commits March 11, 2026 05:43
…instances

Source upstream commit: 19030df

Port scope: adapted (resolved Task/Instances/Shared/Relay conflicts against fork runtime layout).
Source upstream commit: c96e27a

Port scope: adapted (resolved bun.lock conflict with upstream dependency updates).
…d add tests

Source upstream commit: 485ea45

Port scope: not-applicable (fork has no eslint.config.mjs and no scripts/eslint-plugin-evolu.* files).
Source upstream commit: 9803147

Port scope: selective (ported common/vue PURE annotation updates and tree-shaking snapshot changes; skipped missing apps/web path).
Source upstream commit: dfc84a4

Port scope: adapted.
Source upstream commit: 4b2d924

Port scope: adapted.
Source upstream commit: 864bb43

Port scope: adapted.
Source upstream commit: cd6b74d

Port scope: selective (ported Query/Kysely API rename into existing fork structure and examples; skipped missing .changeset and apps/web docs/playgrounds paths).
Source upstream commit: 69a55fb

Port scope: adapted.
Source upstream commit: a0b7371

Port scope: not-applicable (upstream change touches missing apps/web playground path in this fork).
…m guidance

Source upstream commit: ef36be2

Port scope: adapted.
Source upstream commit: c803aef

Port scope: adapted.
Source upstream commit: 617ee37

Port scope: adapted.
…style

Source upstream commit: 768356c

Port scope: adapted.
Source upstream commit: a726b7b

Port scope: adapted (standardized runner variable naming while preserving fork relay/db runtime wiring).
Restore cross-package build compatibility after the sync wave by adding Task API aliases, adapting Sync/Shared wiring to updated Resources semantics, and aligning relay/react-native helpers with updated Result/Promise typings.
… example, and improve resource handling in common resources.
@miccy miccy self-assigned this Mar 11, 2026
@miccy miccy added upstream Needs cherry-pick or merge from upstream fix Repair any bug labels Mar 11, 2026
Copilot AI review requested due to automatic review settings March 11, 2026 08:01
@miccy miccy added the update Update libs and deps label Mar 11, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 11, 2026

📝 Walkthrough

Walkthrough

Velké refaktoringy a funkční rozšíření: přejmenování Runner→Run, rozšíření Ref/Store API, nový asynchronní Resources model, odstranění Instances a přesun Kysely JSON helperů do Query, plus řada drobných anotací a závislostních aktualizací.

Changes

Cohort / File(s) Summary
Core run/task
packages/common/src/Task.ts, packages/web/src/Task.ts
Přejmenování veřejné Runner API na Run (typy, továrny, aliasy, dokumentace).
Ref & Store
packages/common/src/Ref.ts, packages/common/src/Store.ts, packages/common/test/Ref.test.ts, packages/common/test/Store.test.ts
Rozšíření Ref a Store o nové mutátory/čtecí kombinace (getAndSet, setAndGet, update, getAndUpdate, updateAndGet, modify); odstraněno eq-based chování v createRef; odpovídající testy upraveny/rozšířeny.
Resources & Sync/Shared
packages/common/src/Resources.ts, packages/common/src/local-first/Sync.ts, packages/common/src/local-first/Shared.ts
Nový asynchronní, reference-counted resource management (async + legacy cesty), přepracování správy transportů/WebSocketů a lifecycle, veřejné typy Resources/AsyncResourcesConfig/LegacyResources atd.
Instances removal & related tests
packages/common/src/Instances.ts (deleted), packages/common/test/Instances.test.ts (deleted)
Odstraněn multiton Instances util a jeho testy; spotřebované volání nahrazeno Map/novou logikou v příslušných modulech.
Kysely JSON helpers — reorganizace
packages/common/src/local-first/Kysely.ts (deleted), packages/common/src/local-first/Query.ts, packages/common/test/local-first/Query.test.ts, packages/common/test/local-first/Kysely.test.ts
Přesun/rekonstrukce JSON helperů jako veřejné evoluJsonArrayFrom/evoluJsonObjectFrom/evoluJsonBuildObject, přidání kyselySql a aliasu KyselyNotNull; testy a importy aktualizovány.
Relay & Db startup changes
apps/relay/src/startBunRelay.ts, packages/nodejs/src/local-first/Relay.ts, packages/common/src/local-first/Db.ts, packages/common/src/local-first/Relay.ts
Změna z getOk → getOrThrow/explicitní ok-check při použití stack.use(createSqlite(...)); upravená kontrola chyb a konstrukce závislostí/daemon kontextů; drobné refaktory zápisu/kvót a typů.
Local-first storage/schema/timestamp tweaks
packages/common/src/local-first/Schema.ts, packages/common/src/local-first/Storage.ts, packages/common/src/local-first/Timestamp.ts
Nahrazení interních JSON-helperů odkazem na evoluJson*, drobné formatting//*#__PURE__*/ anotace; drobné typové změny (isDelete, zeroFingerprint).
Query JSON utilities & identifier
packages/common/src/local-first/Query.ts
Přidány evoluJson* funkce, getJsonObjectArgs a kyselyJsonIdentifier (runtime id) a re-exporty typu KyselyNotNull / kyselySql.
Sqlite / Sqlite driver
packages/common/src/Sqlite.ts
Refactoring lokálních proměnných a chybového toku v createSqlite; drobné wrappery anotací ve schématu.
Tests — resources rollback / websocket / other
packages/common/test/Resources.test.ts, packages/common/src/WebSocket.ts, packages/common/test/WebSocket.test.ts, další testy...
Přidány rollback testy pro Resources, test helper testCreateWebSocket, změna lifecycle hooků ve WebSocket testu (beforeAll/afterAll), aktualizace testních očekávání a importů dle refactorů.
Misc: tree-shaking & pure markers
packages/common/src/* (Number.ts, Protocol.ts, Schedule.ts, Set.ts, Type.ts, Test.ts, Time.ts, Timestamp.ts, ...)
Mnoho míst doplněno o /*#__PURE__*/ anotace a drobné deklarativní přeuspořádání bez změny chování.
Packages & examples — deps bumps / minor fixes
package.json, biome.json, examples/*/package.json, packages/*/package.json, packages/svelte/package.json, packages/vue/package.json
Několik verzí dependency (turbo, svelte, vue, angular/react-native knihovny) aktualizováno; drobné balíčkové změny.
Index / exports
packages/common/src/index.ts
Odebrány re-exporty Instances a původního kysely namespace; přidány nové re-exporty z Query (kyselySql, evoluJson* a typ KyselyNotNull).

Sequence Diagram(s)

(žádný diagram generován)

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

feat

Suggested reviewers

  • Jakub-coding99

Báseň

🐇 V hopu jsem přepsal Runner na Run,
Ref i Store teď víc umí v jednom hubu,
Resources počítají spotřebu v tichu,
Kysely jsony tančí v Query klubu,
A já nosím mrkvičku kódu v košíku. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Nadpis jasně a stručně popisuje hlavní cíl PR: synchronizaci změn z common-v8, refaktorování rozhraní API a vylepšení manipulace s prostředky.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dev/sync-common-v8-2026-03-11

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@miccy miccy marked this pull request as ready for review March 11, 2026 08:02
@miccy miccy requested a review from a team as a code owner March 11, 2026 08:02
@socket-security
Copy link
Copy Markdown

socket-security bot commented Mar 11, 2026

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm entities is 91.0% likely obfuscated

Confidence: 0.91

Location: Package overview

From: ?npm/typedoc@0.28.17npm/entities@4.5.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/entities@4.5.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm markdown-it is 91.0% likely obfuscated

Confidence: 0.91

Location: Package overview

From: ?npm/typedoc@0.28.17npm/markdown-it@14.1.1

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/markdown-it@14.1.1. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Medium
Deprecated by its maintainer: npm prebuild-install

Reason: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.

From: ?npm/better-sqlite3@12.6.2npm/prebuild-install@7.1.3

ℹ Read more on: This package | This alert | What is a deprecated package?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Research the state of the package and determine if there are non-deprecated versions that can be used, or if it should be replaced with a new, supported solution.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/prebuild-install@7.1.3. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c32892fd10

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

for (const query of [
sql`
create table if not exists evolu_writeKey (
create table evolu_writeKey (
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore idempotent relay table creation

createRelayStorageTables now uses plain CREATE TABLE for relay tables, so rerunning startup against an existing database will fail with table ... already exists and abort relay initialization. This function is used during normal relay boot with persisted SQLite files, so removing IF NOT EXISTS turns a routine restart into a hard failure; keep table creation idempotent (or explicitly gate it behind migration checks).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR syncs updates from common-v8 across the monorepo, focusing on API refactors (notably Ref/Store), improved local-first query JSON handling, and more robust resource/relay lifecycle management, alongside dependency and documentation updates.

Changes:

  • Refactors mutable state utilities (Ref, Store) to a richer, more consistent method set and updates tests accordingly.
  • Consolidates/updates Kysely SQLite JSON helper functionality into local-first/Query.ts with Evolu-specific JSON prefixing + recursive parsing, and updates downstream usages/examples.
  • Refactors resource and worker/relay internals (introducing async Resources, SharedWorker changes, relay startup tweaks) and updates dependencies + docs.

Reviewed changes

Copilot reviewed 53 out of 55 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/web/src/Task.ts Renames browser wording to web-platform wording in docs/group.
packages/vue/src/provideEvolu.ts Adds a lint suppression comment above evoluInstanceMap.
packages/vue/package.json Bumps Vue peer dependency patch.
packages/svelte/package.json Bumps Svelte dependency + peer dependency patch.
packages/react-native/src/Polyfills.ts Improves typing for Promise.try polyfill.
packages/nodejs/src/local-first/Relay.ts Improves sqlite acquisition handling and avoids runner variable shadowing.
packages/common/test/local-first/Query.test.ts Adds coverage for Evolu JSON-prefix Kysely helpers + recursive parsing behavior.
packages/common/test/local-first/Kysely.test.ts Switches tests to new helper exports and loosens error-message matching.
packages/common/test/local-first/Evolu.test.ts Updates tests for export behavior and snapshot stability; wires test WebSocket dep.
packages/common/test/WebSocket.test.ts Switches setup/teardown to beforeAll/afterAll for shared server.
packages/common/test/TreeShaking.test.ts Updates expected bundle sizes after changes.
packages/common/test/Store.test.ts Expands test suite for new Store API and revised equality/notification semantics.
packages/common/test/Resources.test.ts Adds rollback test for legacy resource add-consumer behavior when creation throws.
packages/common/test/Ref.test.ts Expands test suite for new Ref API and updated semantics.
packages/common/test/Instances.test.ts Removes tests for deleted Instances utility.
packages/common/src/local-first/Timestamp.ts Adds /*#__PURE__*/ annotations to branded types.
packages/common/src/local-first/Sync.ts Reworks sync resource handling, websockets management, and mutex storage (Map-based).
packages/common/src/local-first/Storage.ts Adds /*#__PURE__*/ annotations and adjusts type construction.
packages/common/src/local-first/Shared.ts Refactors SharedWorker lifecycle, adds transport resources, and changes SharedEvolu disposal model.
packages/common/src/local-first/Schema.ts Updates docs/types/imports for new Evolu JSON helpers; adds /*#__PURE__*/ on Kysely construction and types.
packages/common/src/local-first/Relay.ts Replaces per-owner mutex multiton with keyed mutex; adjusts write logic and table creation.
packages/common/src/local-first/Query.ts Adds Evolu-prefixed JSON helper implementations, exports Kysely utilities, and improves recursive JSON parsing.
packages/common/src/local-first/Protocol.ts Adds /*#__PURE__*/ annotations to protocol constants.
packages/common/src/local-first/Kysely.ts Removes legacy Kysely helper module (moved into Query.ts).
packages/common/src/local-first/Evolu.ts Makes OnExport without pending export throw (assert) instead of being ignored.
packages/common/src/local-first/Db.ts Removes leader heartbeat sender and changes export payload handling.
packages/common/src/index.ts Removes Instances export and re-exports new Kysely/Evolu JSON helpers/types from Query.ts.
packages/common/src/WebSocket.ts Adds testCreateWebSocket helper.
packages/common/src/Type.ts Adds /*#__PURE__*/ annotations in a few branded type constructors.
packages/common/src/Time.ts Adds /*#__PURE__*/ annotation to branded Millis type constructor.
packages/common/src/Test.ts Adds /*#__PURE__*/ annotation to deterministic entropy bytes.
packages/common/src/Store.ts Updates store API semantics and wiring to match new Ref API.
packages/common/src/Sqlite.ts Refactors driver creation variable naming/logging; adds /*#__PURE__*/ annotations in schema types.
packages/common/src/Set.ts Marks emptySet construction as /*#__PURE__*/.
packages/common/src/Schedule.ts Adds /*#__PURE__*/ annotations inside retry strategy composition.
packages/common/src/Result.ts Removes unused UnknownError import and expands Result docs/guidance.
packages/common/src/Resources.ts Introduces async Resources alongside legacy sync resources, adds rollback behavior, overloads createResources.
packages/common/src/Ref.ts Refactors Ref to richer API (get/set/update/modify variants) and changes semantics.
packages/common/src/Number.ts Adds /*#__PURE__*/ annotation to branded number type constructor.
packages/common/src/Instances.ts Deletes unused Instances multiton utility.
packages/common/package.json Bumps msgpackr and fast-check versions.
package.json Bumps turbo version.
examples/vue-vite-pwa/package.json Bumps Vue patch version.
examples/svelte-vite-pwa/package.json Bumps Svelte patch version.
examples/react-vite-pwa/src/components/EvoluMinimalExample.tsx Updates type narrowing from Evolu.kysely.NotNull to Evolu.KyselyNotNull.
examples/react-nextjs/components/EvoluMinimalExample.tsx Updates type narrowing from Evolu.kysely.NotNull to Evolu.KyselyNotNull.
examples/react-expo/package.json Bumps react-native-safe-area-context.
examples/react-expo/app/index.tsx Updates type narrowing from Evolu.kysely.NotNull to Evolu.KyselyNotNull.
examples/react-electron/components/EvoluMinimalExample.tsx Updates type narrowing from Evolu.kysely.NotNull to Evolu.KyselyNotNull.
examples/angular-vite-pwa/package.json Bumps Angular patch versions.
biome.json Bumps Biome schema URL.
apps/relay/src/startBunRelay.ts Uses getOrThrow for sqlite acquisition.
Comments suppressed due to low confidence (1)

packages/common/src/local-first/Relay.ts:324

  • createRelayStorageTables now uses plain create table ... without if not exists. This will throw on subsequent relay starts against an existing database (e.g., apps/relay/src/startBunRelay.ts calls createRelayStorageTables(deps) unconditionally). Restore if not exists here, or make the call sites guard table creation based on DB existence/migrations.

Comment on lines 13 to 15
// eslint-disable-next-line evolu/require-pure-annotation
export const evoluInstanceMap = new WeakMap<
ComponentInternalInstance,
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file adds an eslint-disable-next-line evolu/require-pure-annotation directive, but the repo uses Biome (and has no ESLint config), so this suppression is likely ineffective and adds tooling noise. Prefer addressing the underlying requirement (e.g., add a /*#__PURE__*/ annotation if tree-shaking needs it) or use a Biome suppression if there’s an actual Biome rule to silence.

Copilot uses AI. Check for mistakes.
Comment on lines +195 to +203
onDispose: () => {
void runWithSharedEvoluDeps.daemon(
sharedEvolusMutexByName.withLock(message.name, () => {
sharedEvolusByName.delete(message.name);
return ok();
}),
);
},
}),
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SharedEvolu now implements AsyncDisposable, but the onDispose callback passed to createSharedEvolu only deletes the instance from sharedEvolusByName and never calls sharedEvolu[Symbol.asyncDispose](). When the last port disposes (if (evoluPorts.size === 0) onDispose()), this leaks the callbacks/timeout/fiber state and (when appOwner is set) keeps transports.addConsumer references alive. Update the disposal path to actually dispose the SharedEvolu instance (and ideally await it) before removing it from the map.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (6)
packages/common/src/Task.ts (1)

1742-1751: Zvažte zjednodušení extrakce task argumentu.

IIFE na řádcích 1742-1746 lze zjednodušit na ternární výraz, protože logika je přímočará.

♻️ Návrh zjednodušení
 export function concurrently<T, E, D = unknown>(
   concurrencyOrTask: Concurrency | Task<T, E, D>,
   taskOrFallback?: Task<T, E, D>,
 ): Task<T, E, D> {
   const isTask = isFunction(concurrencyOrTask);
-  const task = (() => {
-    if (isTask) return concurrencyOrTask;
-    assert(taskOrFallback, "Task is required when concurrency is provided.");
-    return taskOrFallback;
-  })();
+  if (!isTask) {
+    assert(taskOrFallback, "Task is required when concurrency is provided.");
+  }
+  const task = isTask ? concurrencyOrTask : taskOrFallback!;

   return Object.assign((run: Run<D>) => run(task), {
     [concurrencyBehaviorSymbol]: isTask ? maxPositiveInt : concurrencyOrTask,
   });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/Task.ts` around lines 1742 - 1751, The IIFE used to
derive task can be replaced with a single ternary expression: directly set task
= isTask ? concurrencyOrTask : (assert(taskOrFallback, "Task is required when
concurrency is provided."), taskOrFallback) or otherwise ensure the assertion
runs and task gets taskOrFallback when isTask is false; update the code that
returns Object.assign((run: Run<D>) => run(task), { [concurrencyBehaviorSymbol]:
isTask ? maxPositiveInt : concurrencyOrTask }) to use the simplified task
binding (referencing isTask, concurrencyOrTask, taskOrFallback,
concurrencyBehaviorSymbol, and maxPositiveInt).
packages/react-native/src/Polyfills.ts (1)

37-40: Preferujte readonly args a odstraňte zbytečnou aserci.

args se nikde nemění, takže Array<unknown> zbytečně zužuje veřejný podpis. Promise.resolve(func(...args)) automaticky vrací Promise<Awaited<T>> bez nutnosti explicitní aserce.

♻️ Navržená úprava
-    try?: <T, U extends Array<unknown>>(
+    try?: <T, U extends ReadonlyArray<unknown>>(
       func: (...args: U) => T | PromiseLike<T>,
       ...args: U
     ) => Promise<Awaited<T>>;
@@
-    PromiseStatic.try = <T, U extends Array<unknown>>(
+    PromiseStatic.try = <T, U extends ReadonlyArray<unknown>>(
       func: (...args: U) => T | PromiseLike<T>,
       ...args: U
-    ): Promise<Awaited<T>> =>
-      new Promise<Awaited<T>>((resolve, reject) => {
-        try {
-          resolve(func(...args) as Awaited<T>);
-        } catch (error) {
-          reject(error);
-        }
-      });
+    ): Promise<Awaited<T>> => {
+      try {
+        return Promise.resolve(func(...args));
+      } catch (error) {
+        return Promise.reject(error);
+      }
+    };

Platí pro: 37–40, 58–68

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-native/src/Polyfills.ts` around lines 37 - 40, Upravte veřejné
podpisy metody/variantu označené jako "try?" (a obdobné přetížení v rozsahu
58–68) tak, aby používaly readonly typ pro argumenty (např. ...args: readonly
unknown[]) místo mutovatelného Array<unknown>, a odstraňte zbytečnou explicitní
aserci typu při vracení Promise (ponechte Promise.resolve(func(...args)) bez "as
Promise<Awaited<T>>"), čímž zachováte správný návratový typ Awaited<T> a
reflektujete, že args se nemění.
packages/common/test/WebSocket.test.ts (1)

21-42: Sdílený WS server mezi testy zhoršuje izolaci.

Tahle změna zavádí jednu společnou serverovou instanci a globální port pro celý soubor. Když některý test nechá otevřený socket nebo změní serverový stav, další testy už neběží nad čistým prostředím, takže vznikající nestabilitu bude těžké reprodukovat. Pokud chcete zůstat u beforeAll, přidejte aspoň explicitní reset serveru mezi testy.

Based on learnings "Never rely on global state or shared mutable deps between tests. Create fresh deps at the start of each test".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/test/WebSocket.test.ts` around lines 21 - 42, The tests share
a single global WebSocket server via the module-level variable port and
beforeAll/afterAll (createServer/closeServer, startWsServer/stopWsServer), which
breaks isolation; change to create and tear down a fresh server for each test
(use beforeEach/afterEach or call an explicit reset between tests) so that each
test invokes createServer/startWsServer and closes it with
closeServer/stopWsServer (or resets server state) and remove reliance on the
global port variable to ensure no leftover sockets or mutated server state leak
between tests.
packages/common/test/Ref.test.ts (1)

11-133: Přidejte i compile-time aserce pro nové Ref API.

Runtime pokrytí je tu dobré, ale hlavní změna API je i typová. Bez expectTypeOf nezachytíte regresi jako set(): void nebo změněný návratový typ u getAndSet / setAndGet / modify.

💡 Možné doplnění
-import { describe, expect, test } from "vitest";
+import { describe, expect, expectTypeOf, test } from "vitest";
 import { createRef } from "../src/Ref.js";

+test("preserves the Ref type contract", () => {
+  const ref = createRef(1);
+
+  expectTypeOf(ref.set(2)).toEqualTypeOf<void>();
+  expectTypeOf(ref.getAndSet(2)).toEqualTypeOf<number>();
+  expectTypeOf(ref.setAndGet(2)).toEqualTypeOf<number>();
+  expectTypeOf(
+    ref.modify((current) => [`value:${current}`, current + 1] as const),
+  ).toEqualTypeOf<string>();
+});
+
 describe("get", () => {
As per coding guidelines "Use `expectTypeOf` from Vitest for compile-time type assertions in tests".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/test/Ref.test.ts` around lines 11 - 133, Add compile-time
type assertions using Vitest's expectTypeOf for the new Ref API: import
expectTypeOf and assert types for createRef return and each method signature
(createRef, Ref.set, Ref.get, getAndSet, setAndGet, update, getAndUpdate,
updateAndGet, modify) to ensure set returns void, get returns the state type,
getAndSet/setAndGet/modify return the correct value types, and updater callbacks
accept and return the state type; place these expectTypeOf checks alongside the
runtime tests to catch type regressions.
packages/common/src/local-first/Sync.ts (1)

430-444: Type casting pro kontrolu AbortError.

Type casting (result.error as { type?: string }).type je křehký přístup. Zvažte zavedení type guard funkce pro kontrolu AbortError.

♻️ Návrh type guard funkce
const isAbortError = (error: unknown): boolean =>
  typeof error === "object" &&
  error !== null &&
  "type" in error &&
  (error as { type: unknown }).type === "AbortError";

Pak lze použít:

-if ((result.error as { type?: string }).type !== "AbortError") {
+if (!isAbortError(result.error)) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/local-first/Sync.ts` around lines 430 - 444, Replace the
fragile cast when checking for an AbortError in the syncRun result handler by
adding a reusable type guard (e.g., isAbortError) and using it instead of
`(result.error as { type?: string }).type`; update the success/error callback in
syncRun where you currently call
config.onError(createUnknownError(result.error)) and the conditional `if
((result.error as { type?: string }).type !== "AbortError")` to use
isAbortError(result.error) to decide whether to call createUnknownError, keeping
the surrounding logic (syncOwnerRefs.has(owner.id),
sendSubscribeForOwner(owner)) unchanged.
packages/common/src/Store.ts (1)

94-99: Nadbytečné volání ref.get() v updateAndGet.

Metoda ref.updateAndGet(updater) již vrací aktualizovaný stav, takže následné volání ref.get() je redundantní. Pro konzistenci s ostatními metodami by stačilo vrátit výsledek přímo.

♻️ Možná optimalizace
     updateAndGet: (updater) => {
       const previousState = ref.get();
-      ref.updateAndGet(updater);
+      const newState = ref.updateAndGet(updater);
       notifyIfChanged(previousState);
-      return ref.get();
+      return newState;
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/Store.ts` around lines 94 - 99, The updateAndGet
implementation calls ref.updateAndGet(updater) but then redundantly calls
ref.get(); change it to capture and return the value returned by
ref.updateAndGet(updater) instead of calling ref.get(), while still calling
notifyIfChanged(previousState) as before; update the function named updateAndGet
to set something like const newState = ref.updateAndGet(updater);
notifyIfChanged(previousState); return newState so you avoid the extra ref.get()
call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/common/src/local-first/Evolu.ts`:
- Around line 766-773: The OnExport handler currently asserts that
exportDatabasePending exists, which crashes if an OnExport arrives during/after
asyncDispose; modify the case "OnExport" in the message handler to replace the
assert with a conditional: if exportDatabasePending is non-null call
exportDatabasePending.resolve(message.file) and set exportDatabasePending =
null, otherwise ignore the message (optionally log a debug message); ensure this
change covers race scenarios between exportDatabase() and asyncDispose() and
references the symbols exportDatabasePending, exportDatabase(), asyncDispose,
and the "OnExport" case.

In `@packages/common/src/local-first/Query.ts`:
- Around line 123-127: makePatches is still comparing values via eqSqliteValue
after casting to SqliteValue, which generates false patches for nested JSON
returned by evoluJsonObjectFrom/evoluJsonArrayFrom because parseSqliteJsonArray
produces new object references; update makePatches to detect when a Row value is
an object or array (per the Row type) and perform a structural/deep equality
check (or compare canonical JSON) instead of eqSqliteValue for those cases;
adjust logic around makePatches, eqSqliteValue, and parseSqliteJsonArray so
nested parsed JSON is compared structurally rather than by reference to avoid
spurious diffs.

In `@packages/common/src/local-first/Relay.ts`:
- Around line 114-115: deleteOwner currently bypasses the same per-owner keyed
mutex used by writeMessages (mutexByOwnerId), allowing concurrent
deleteOwner(ownerId) and writeMessages(ownerId, ...) to race; fix by running the
entire deleteOwner body under the same keyed lock (use
mutexByOwnerId.run(ownerId, async () => { ... }) or equivalent) so deletes and
writes for the same OwnerId are serialized, and apply the same change to the
other delete path mentioned (the block around the 283-300 region) so all
owner-deletion code uses mutexByOwnerId for synchronization.
- Around line 184-190: Duplicate timestamps in the incoming batch are counted
twice for quota even though the DB will store only one row; before computing
incomingBytes and calling updateOwnerUsage, deduplicate
messagesWithTimestampBytes by timestamp.toString() (e.g., build a Map keyed by
timestamp string and keep the first/representative message) to produce a
dedupedMessages array, then use that deduped array when creating
existingTimestampsSet filtering, calculating incomingBytes, and before the
insert (affects the logic around existingTimestampsSet,
messagesWithTimestampBytes, newMessages and updateOwnerUsage; also apply same
fix in the similar block around lines 205–245).

In `@packages/common/src/local-first/Shared.ts`:
- Around line 195-202: Callback onDispose currently schedules a delete via
runWithSharedEvoluDeps.daemon + sharedEvolusMutexByName.withLock but doesn't
verify the instance identity or call the instance's async disposer, so a newly
created instance with the same name can be removed and transports/timeouts
remain registered; change the onDispose handler to (1) capture the specific
sharedEvolu instance at creation time, (2) inside the mutexed function compare
sharedEvolusByName.get(message.name) === capturedInstance before deleting, and
(3) if deleting, invoke await capturedInstance[Symbol.asyncDispose]() (or call
its async dispose method) to unregister transports/timeouts before removing the
map entry (references: onDispose, runWithSharedEvoluDeps.daemon,
sharedEvolusMutexByName.withLock, sharedEvolusByName, CreateEvolu, sharedEvolu,
Symbol.asyncDispose).
- Around line 530-558: The dispose branch currently removes evoluPorts and queue
entries but doesn't clear the corresponding db worker mapping, which can leave
activeDbWorkerPort pointing at a closed tab; add and maintain a mapping (e.g.,
dbWorkerPorts: Map<evoluPortId, DbWorkerPort>) and in this dispose block delete
dbWorkerPorts.delete(evoluPortId) and if activeDbWorkerPort refers to that
evoluPortId then immediately drop the leader by setting activeDbWorkerPort =
null and performing the same cleanup you do when isActiveQueueItemDisposed
(clearActiveLeaderTimeout, abort queueProcessingFiber, cancel
activeQueueCallbackId), then continue with evoluPorts.delete,
rowsByQueryByEvoluPortId.delete, queue splices and ensureQueueProcessing so no
requests are sent to a dead port.
- Around line 159-162: The cache keyed only by Name (sharedEvolusByName,
sharedEvolusMutexByName) causes createSharedEvolu and transports.addConsumer to
be skipped when the first CreateEvolu for a name has no appOwner or a different
owner; change the caching strategy so the key includes both name and appOwner
(or owner identifier) OR detect owner changes and re-run createSharedEvolu and
transports.addConsumer for the existing SharedEvolu; update
runWithSharedEvoluDeps usage and the lookup logic around
sharedEvolusByName/sharedEvolusMutexByName (and the code block referenced at
183-214) to use the composite key (name+appOwner) or to patch an existing
instance by invoking createSharedEvolu/transports.addConsumer when owner
differs.

In `@packages/common/src/WebSocket.ts`:
- Around line 321-337: The testCreateWebSocket fake currently keeps reporting an
open socket even after [Symbol.dispose] is called; modify testCreateWebSocket to
maintain an internal "closed" flag set when [Symbol.dispose]() runs and make
send, isOpen and getReadyState consult that flag: after disposal isOpen() should
return false, getReadyState() should return "closed", and send() should fail
(return an error/result indicating closed) instead of succeeding. Update the
returned object in testCreateWebSocket to use this internal state so
cleanup-path tests behave like a real closed socket.

---

Nitpick comments:
In `@packages/common/src/local-first/Sync.ts`:
- Around line 430-444: Replace the fragile cast when checking for an AbortError
in the syncRun result handler by adding a reusable type guard (e.g.,
isAbortError) and using it instead of `(result.error as { type?: string
}).type`; update the success/error callback in syncRun where you currently call
config.onError(createUnknownError(result.error)) and the conditional `if
((result.error as { type?: string }).type !== "AbortError")` to use
isAbortError(result.error) to decide whether to call createUnknownError, keeping
the surrounding logic (syncOwnerRefs.has(owner.id),
sendSubscribeForOwner(owner)) unchanged.

In `@packages/common/src/Store.ts`:
- Around line 94-99: The updateAndGet implementation calls
ref.updateAndGet(updater) but then redundantly calls ref.get(); change it to
capture and return the value returned by ref.updateAndGet(updater) instead of
calling ref.get(), while still calling notifyIfChanged(previousState) as before;
update the function named updateAndGet to set something like const newState =
ref.updateAndGet(updater); notifyIfChanged(previousState); return newState so
you avoid the extra ref.get() call.

In `@packages/common/src/Task.ts`:
- Around line 1742-1751: The IIFE used to derive task can be replaced with a
single ternary expression: directly set task = isTask ? concurrencyOrTask :
(assert(taskOrFallback, "Task is required when concurrency is provided."),
taskOrFallback) or otherwise ensure the assertion runs and task gets
taskOrFallback when isTask is false; update the code that returns
Object.assign((run: Run<D>) => run(task), { [concurrencyBehaviorSymbol]: isTask
? maxPositiveInt : concurrencyOrTask }) to use the simplified task binding
(referencing isTask, concurrencyOrTask, taskOrFallback,
concurrencyBehaviorSymbol, and maxPositiveInt).

In `@packages/common/test/Ref.test.ts`:
- Around line 11-133: Add compile-time type assertions using Vitest's
expectTypeOf for the new Ref API: import expectTypeOf and assert types for
createRef return and each method signature (createRef, Ref.set, Ref.get,
getAndSet, setAndGet, update, getAndUpdate, updateAndGet, modify) to ensure set
returns void, get returns the state type, getAndSet/setAndGet/modify return the
correct value types, and updater callbacks accept and return the state type;
place these expectTypeOf checks alongside the runtime tests to catch type
regressions.

In `@packages/common/test/WebSocket.test.ts`:
- Around line 21-42: The tests share a single global WebSocket server via the
module-level variable port and beforeAll/afterAll (createServer/closeServer,
startWsServer/stopWsServer), which breaks isolation; change to create and tear
down a fresh server for each test (use beforeEach/afterEach or call an explicit
reset between tests) so that each test invokes createServer/startWsServer and
closes it with closeServer/stopWsServer (or resets server state) and remove
reliance on the global port variable to ensure no leftover sockets or mutated
server state leak between tests.

In `@packages/react-native/src/Polyfills.ts`:
- Around line 37-40: Upravte veřejné podpisy metody/variantu označené jako
"try?" (a obdobné přetížení v rozsahu 58–68) tak, aby používaly readonly typ pro
argumenty (např. ...args: readonly unknown[]) místo mutovatelného
Array<unknown>, a odstraňte zbytečnou explicitní aserci typu při vracení Promise
(ponechte Promise.resolve(func(...args)) bez "as Promise<Awaited<T>>"), čímž
zachováte správný návratový typ Awaited<T> a reflektujete, že args se nemění.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4a62656c-09dd-4060-822c-dad96857d728

📥 Commits

Reviewing files that changed from the base of the PR and between 05bc57b and c32892f.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (54)
  • apps/relay/src/startBunRelay.ts
  • biome.json
  • examples/angular-vite-pwa/package.json
  • examples/react-electron/components/EvoluMinimalExample.tsx
  • examples/react-expo/app/index.tsx
  • examples/react-expo/package.json
  • examples/react-nextjs/components/EvoluMinimalExample.tsx
  • examples/react-vite-pwa/src/components/EvoluMinimalExample.tsx
  • examples/svelte-vite-pwa/package.json
  • examples/vue-vite-pwa/package.json
  • package.json
  • packages/common/package.json
  • packages/common/src/Instances.ts
  • packages/common/src/Number.ts
  • packages/common/src/Ref.ts
  • packages/common/src/Resources.ts
  • packages/common/src/Result.ts
  • packages/common/src/Schedule.ts
  • packages/common/src/Set.ts
  • packages/common/src/Sqlite.ts
  • packages/common/src/Store.ts
  • packages/common/src/Task.ts
  • packages/common/src/Test.ts
  • packages/common/src/Time.ts
  • packages/common/src/Type.ts
  • packages/common/src/WebSocket.ts
  • packages/common/src/index.ts
  • packages/common/src/local-first/Db.ts
  • packages/common/src/local-first/Evolu.ts
  • packages/common/src/local-first/Kysely.ts
  • packages/common/src/local-first/Protocol.ts
  • packages/common/src/local-first/Query.ts
  • packages/common/src/local-first/Relay.ts
  • packages/common/src/local-first/Schema.ts
  • packages/common/src/local-first/Shared.ts
  • packages/common/src/local-first/Storage.ts
  • packages/common/src/local-first/Sync.ts
  • packages/common/src/local-first/Timestamp.ts
  • packages/common/test/Instances.test.ts
  • packages/common/test/Ref.test.ts
  • packages/common/test/Resources.test.ts
  • packages/common/test/Store.test.ts
  • packages/common/test/Task.test.ts
  • packages/common/test/TreeShaking.test.ts
  • packages/common/test/WebSocket.test.ts
  • packages/common/test/local-first/Evolu.test.ts
  • packages/common/test/local-first/Kysely.test.ts
  • packages/common/test/local-first/Query.test.ts
  • packages/nodejs/src/local-first/Relay.ts
  • packages/react-native/src/Polyfills.ts
  • packages/svelte/package.json
  • packages/vue/package.json
  • packages/vue/src/provideEvolu.ts
  • packages/web/src/Task.ts
💤 Files with no reviewable changes (3)
  • packages/common/src/local-first/Kysely.ts
  • packages/common/test/Instances.test.ts
  • packages/common/src/Instances.ts

Comment on lines 766 to 773
case "OnExport": {
if (exportDatabasePending) {
exportDatabasePending.resolve(message.file);
exportDatabasePending = null;
}
// Silently ignore late OnExport after disposal
assert(
exportDatabasePending,
"OnExport received without pending export.",
);
exportDatabasePending.resolve(message.file);
exportDatabasePending = null;
break;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Hledání, jak je řešen disposal v jiných message handlerech
ast-grep --pattern $'case "$_": {
  $$$
  assert($_, $_);
  $$$
}'

# Kontrola, zda existují testy pro disposal během exportu
rg -n "OnExport|exportDatabase" --type ts -g '*test*'

Repository: SQLoot/evolu-plan-b

Length of output: 2330


🏁 Script executed:

#!/bin/bash
# Read the disposal code (lines 892-896)
sed -n '880,910p' packages/common/src/local-first/Evolu.ts

# Read the export test that checks the assertion
sed -n '1203,1240p' packages/common/test/local-first/Evolu.test.ts

# Check if there are tests combining disposal and exportDatabase
rg -n "dispose|asyncDispose" packages/common/test/local-first/Evolu.test.ts -B 3 -A 3 | head -100

Repository: SQLoot/evolu-plan-b

Length of output: 5044


🏁 Script executed:

#!/bin/bash
# Check the message handling loop to understand ordering
sed -n '750,810p' packages/common/src/local-first/Evolu.ts

# Check how worker messages are processed
rg -n "postEvoluOutput|OnExport" packages/common/src/local-first/Evolu.ts -B 2 -A 5 | head -80

Repository: SQLoot/evolu-plan-b

Length of output: 2212


🏁 Script executed:

#!/bin/bash
# Look for the complete asyncDispose implementation
sed -n '880,920p' packages/common/src/local-first/Evolu.ts

# Check the entire OnExport case and surrounding code
sed -n '760,800p' packages/common/src/local-first/Evolu.ts

Repository: SQLoot/evolu-plan-b

Length of output: 2561


Opravit ráci condition při zásahu OnExport po dispose.

Aserce selže, pokud OnExport zpráva dorazí během nebo krátce po asyncDispose:

  1. exportDatabase() nastaví exportDatabasePending a odešle "Export" zprávu do workeru
  2. asyncDispose() nastaví exportDatabasePending = null a odešle "Dispose" zprávu
  3. Worker může poslat OnExport zprávu dříve, než se Dispose zpráva zpracuje (nebo pokud byl export již zahájen)
  4. Handler obdrží OnExport s exportDatabasePending === null → aserce selže a aplikace spadne

Vzhledem k tomu, že test "does not execute mutate onComplete callback after dispose" (linie 527) očekává graceful handling pozdních zpráv po dispose, by měla aserce být nahrazena podmínkou:

Návrh opravy
 case "OnExport": {
-  assert(
-    exportDatabasePending,
-    "OnExport received without pending export.",
-  );
-  exportDatabasePending.resolve(message.file);
-  exportDatabasePending = null;
+  if (exportDatabasePending) {
+    exportDatabasePending.resolve(message.file);
+    exportDatabasePending = null;
+  }
+  // Ignore late OnExport after disposal - race with asyncDispose is expected.
   break;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/local-first/Evolu.ts` around lines 766 - 773, The
OnExport handler currently asserts that exportDatabasePending exists, which
crashes if an OnExport arrives during/after asyncDispose; modify the case
"OnExport" in the message handler to replace the assert with a conditional: if
exportDatabasePending is non-null call
exportDatabasePending.resolve(message.file) and set exportDatabasePending =
null, otherwise ignore the message (optionally log a debug message); ensure this
change covers race scenarios between exportDatabase() and asyncDispose() and
references the symbols exportDatabasePending, exportDatabase(), asyncDispose,
and the "OnExport" case.

Comment on lines 123 to +127
export interface Row {
readonly [key: string]:
| SqliteValue
| Row // for jsonObjectFrom from kysely/helpers/sqlite
| ReadonlyArray<Row>; // for jsonArrayFrom from kysely/helpers/sqlite
| Row // for evoluJsonObjectFrom
| ReadonlyArray<Row>; // for evoluJsonArrayFrom
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Vnořené JSON výsledky teď vypadají jako změna při každém refreshi.

Row teď připouští objekty a pole, ale makePatches v tomhle souboru stále porovnává hodnoty přes eqSqliteValue(...) po castu na SqliteValue. Jakmile query vrátí evoluJsonObjectFrom nebo evoluJsonArrayFrom, po parseSqliteJsonArray dostanete nové objektové reference a diff začne generovat falešné patche i bez datové změny.

💡 Možná oprava
+const eqRowValue = (a: unknown, b: unknown): boolean => {
+  if (Array.isArray(a) && Array.isArray(b)) {
+    return a.length === b.length && a.every((value, index) => eqRowValue(value, b[index]));
+  }
+
+  if (isPlainObject(a) && isPlainObject(b)) {
+    const aKeys = Object.keys(a);
+    const bKeys = Object.keys(b);
+    return (
+      aKeys.length === bKeys.length &&
+      aKeys.every((key) => eqRowValue(a[key], b[key]))
+    );
+  }
+
+  return eqSqliteValue(a as SqliteValue, b as SqliteValue);
+};
+
 export const makePatches = (
   previousRows: ReadonlyArray<Row> | undefined,
   nextRows: ReadonlyArray<Row>,
 ): ReadonlyArray<Patch> => {
@@
-      if (
-        !eqSqliteValue(
-          previousRow[key] as SqliteValue,
-          nextRow[key] as SqliteValue,
-        )
-      ) {
+      if (!eqRowValue(previousRow[key], nextRow[key])) {
         replaceAtPatches.push({ op: "replaceAt", value: nextRow, index: i });
         break;
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/local-first/Query.ts` around lines 123 - 127, makePatches
is still comparing values via eqSqliteValue after casting to SqliteValue, which
generates false patches for nested JSON returned by
evoluJsonObjectFrom/evoluJsonArrayFrom because parseSqliteJsonArray produces new
object references; update makePatches to detect when a Row value is an object or
array (per the Row type) and perform a structural/deep equality check (or
compare canonical JSON) instead of eqSqliteValue for those cases; adjust logic
around makePatches, eqSqliteValue, and parseSqliteJsonArray so nested parsed
JSON is compared structurally rather than by reference to avoid spurious diffs.

Comment on lines +114 to +115
/** Mutex keyed by OwnerId to prevent concurrent writes. */
const mutexByOwnerId = createMutexByKey<OwnerId>();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

deleteOwner obchází stejný keyed lock jako writeMessages.

Zápisy zpráv jsou teď serializované přes mutexByOwnerId, ale mazání stejného ownera ne. Paralelní deleteOwner(ownerId) a writeMessages(ownerId, ...) tak může ownera smazat a hned znovu částečně vytvořit, včetně rozjetých usage metadat. Mazání musí běžet pod stejnou synchronizací jako zápisy.

Also applies to: 283-300

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/local-first/Relay.ts` around lines 114 - 115, deleteOwner
currently bypasses the same per-owner keyed mutex used by writeMessages
(mutexByOwnerId), allowing concurrent deleteOwner(ownerId) and
writeMessages(ownerId, ...) to race; fix by running the entire deleteOwner body
under the same keyed lock (use mutexByOwnerId.run(ownerId, async () => { ... })
or equivalent) so deletes and writes for the same OwnerId are serialized, and
apply the same change to the other delete path mentioned (the block around the
283-300 region) so all owner-deletion code uses mutexByOwnerId for
synchronization.

Comment on lines +184 to +190
const existingTimestampsSet = new Set(
existingTimestampsResult.map((t) => t.toString()),
);
const newMessages = filterArray(
messagesWithTimestampBytes,
(m) => !existingTimestampsSet.has(m.timestamp.toString()),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Deduplikujte timestamp ještě před výpočtem kvóty.

existingTimestampsSet odfiltruje jen to, co už je v DB. Když jeden batch obsahuje stejný timestamp dvakrát, obě položky projdou do newMessages, incomingBytes se započítá 2× a updateOwnerUsage navýší usage, i když insert into evolu_message ... on conflict do nothing uloží jen jednu řádku. Tím se rozbije accounting a můžete vracet falešný StorageQuotaError.

Možná oprava
-              const existingTimestampsSet = new Set(
+              const seenTimestamps = new Set(
                 existingTimestampsResult.map((t) => t.toString()),
               );
               const newMessages = filterArray(
                 messagesWithTimestampBytes,
-                (m) => !existingTimestampsSet.has(m.timestamp.toString()),
+                (m) => {
+                  const timestamp = m.timestamp.toString();
+                  if (seenTimestamps.has(timestamp)) return false;
+                  seenTimestamps.add(timestamp);
+                  return true;
+                },
               );

Also applies to: 205-245

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/local-first/Relay.ts` around lines 184 - 190, Duplicate
timestamps in the incoming batch are counted twice for quota even though the DB
will store only one row; before computing incomingBytes and calling
updateOwnerUsage, deduplicate messagesWithTimestampBytes by timestamp.toString()
(e.g., build a Map keyed by timestamp string and keep the first/representative
message) to produce a dedupedMessages array, then use that deduped array when
creating existingTimestampsSet filtering, calculating incomingBytes, and before
the insert (affects the logic around existingTimestampsSet,
messagesWithTimestampBytes, newMessages and updateOwnerUsage; also apply same
fix in the similar block around lines 205–245).

Comment on lines +159 to 162
const runWithSharedEvoluDeps = run.addDeps({ transports });
const sharedEvolusByName = new Map<Name, SharedEvolu>();
const sharedEvolusMutexByName = stack.use(createMutexByKey<Name>());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reuse jen podle name ignoruje později dodaný appOwner.

Stačí, aby první CreateEvolu přišel bez appOwner. Další CreateEvolu pro stejné name už znovu použije existující instanci, takže createSharedEvolu(...) neběží a transports.addConsumer(...) se nikdy nespustí. Stejný problém nastane i při změně ownera pro stejné jméno.

Also applies to: 183-214

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/local-first/Shared.ts` around lines 159 - 162, The cache
keyed only by Name (sharedEvolusByName, sharedEvolusMutexByName) causes
createSharedEvolu and transports.addConsumer to be skipped when the first
CreateEvolu for a name has no appOwner or a different owner; change the caching
strategy so the key includes both name and appOwner (or owner identifier) OR
detect owner changes and re-run createSharedEvolu and transports.addConsumer for
the existing SharedEvolu; update runWithSharedEvoluDeps usage and the lookup
logic around sharedEvolusByName/sharedEvolusMutexByName (and the code block
referenced at 183-214) to use the composite key (name+appOwner) or to patch an
existing instance by invoking createSharedEvolu/transports.addConsumer when
owner differs.

Comment on lines +195 to +202
onDispose: () => {
void runWithSharedEvoluDeps.daemon(
sharedEvolusMutexByName.withLock(message.name, () => {
sharedEvolusByName.delete(message.name);
return ok();
}),
);
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

onDispose může smazat z mapy znovu aktivní instanci a nikdy ji nedisponuje.

Mazání je odložené přes daemon(...) a bez kontroly identity. Když mezi Dispose a tímto callbackem přijde nové CreateEvolu pro stejné name, instance dostane nové porty, ale pozdější onDispose ji stejně odstraní z mapy. Navíc se tu vůbec nevolá sharedEvolu[Symbol.asyncDispose](), takže zůstanou registrované transporty, timeouty a callbacky.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/local-first/Shared.ts` around lines 195 - 202, Callback
onDispose currently schedules a delete via runWithSharedEvoluDeps.daemon +
sharedEvolusMutexByName.withLock but doesn't verify the instance identity or
call the instance's async disposer, so a newly created instance with the same
name can be removed and transports/timeouts remain registered; change the
onDispose handler to (1) capture the specific sharedEvolu instance at creation
time, (2) inside the mutexed function compare
sharedEvolusByName.get(message.name) === capturedInstance before deleting, and
(3) if deleting, invoke await capturedInstance[Symbol.asyncDispose]() (or call
its async dispose method) to unregister transports/timeouts before removing the
map entry (references: onDispose, runWithSharedEvoluDeps.daemon,
sharedEvolusMutexByName.withLock, sharedEvolusByName, CreateEvolu, sharedEvolu,
Symbol.asyncDispose).

Comment on lines +530 to 558
const isActiveQueueItemDisposed =
isNonEmptyArray(queue) && queue[0].evoluPortId === evoluPortId;

evoluPorts.delete(evoluPortId);
rowsByQueryByEvoluPortId.delete(evoluPortId);

for (let i = queue.length - 1; i >= 0; i -= 1) {
if (queue[i].evoluPortId === evoluPortId) {
queue.splice(i, 1);
}
}

if (isActiveQueueItemDisposed) {
clearActiveLeaderTimeout();
queueProcessingFiber?.abort();
queueProcessingFiber = null;

if (activeQueueCallbackId) {
callbacks.cancel(activeQueueCallbackId);
activeQueueCallbackId = null;
}

// Require leader reacquire before dispatching remaining queue.
activeDbWorkerPort = null;
}

if (activeDbWorkerPort) ensureQueueProcessing();
if (evoluPorts.size === 0) onDispose();
ensureQueueProcessing();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Po Dispose může activeDbWorkerPort dál ukazovat na zavřený tab.

Tahle větev maže jen evoluPort a frontu, ale nepáruje odpovídající dbWorkerPort. Pokud zavřený tab držel leadera bez aktivní queue položky, activeDbWorkerPort zůstane nenulový a další requesty se budou posílat do mrtvého portu až do 35s timeoutu. Uložte vazbu evoluPortId -> dbWorkerPort a při dispose ji vždy odstraňte, případně leadera hned shoďte.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/local-first/Shared.ts` around lines 530 - 558, The
dispose branch currently removes evoluPorts and queue entries but doesn't clear
the corresponding db worker mapping, which can leave activeDbWorkerPort pointing
at a closed tab; add and maintain a mapping (e.g., dbWorkerPorts:
Map<evoluPortId, DbWorkerPort>) and in this dispose block delete
dbWorkerPorts.delete(evoluPortId) and if activeDbWorkerPort refers to that
evoluPortId then immediately drop the leader by setting activeDbWorkerPort =
null and performing the same cleanup you do when isActiveQueueItemDisposed
(clearActiveLeaderTimeout, abort queueProcessingFiber, cancel
activeQueueCallbackId), then continue with evoluPorts.delete,
rowsByQueryByEvoluPortId.delete, queue splices and ensureQueueProcessing so no
requests are sent to a dead port.

Comment on lines +249 to +297
addConsumer: (consumer, resourceConfigs) => async (run) => {
if (disposing) return ok();

const consumerId = getConsumerId(consumer);

for (const resourceConfig of resourceConfigs) {
if (disposing) return ok();

const resourceId = getResourceId(resourceConfig);
resourceIdsWithMutex.add(resourceId);

const result = await run(
unabortable(
mutexByResourceId.withLock(resourceId, async () => {
if (disposing) return ok();
clearDisposalTimeout(resourceId);

let resource = resourcesById.get(resourceId);
if (!resource) {
resource = await createResource(resourceConfig);
resourcesById.set(resourceId, resource);
}

let consumerRefCountsByConsumerId =
consumerRefCountsByResourceId.get(resourceId);
if (!consumerRefCountsByConsumerId) {
consumerRefCountsByConsumerId = createRefCount<TConsumerId>();
consumerRefCountsByResourceId.set(
resourceId,
consumerRefCountsByConsumerId,
);
}

const nextCount =
consumerRefCountsByConsumerId.increment(consumerId);

if (nextCount === 1) {
consumerIdsByResourceId.add(resourceId, consumerId);
}

return ok();
}),
),
);
if (!result.ok) return result;
}

return ok();
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Ověření, zda existují testy pokrývající selhání createResource v async path
rg -n "addConsumer.*fail|createResource.*throw|createResource.*error" packages/common/test/

Repository: SQLoot/evolu-plan-b

Length of output: 180


🏁 Script executed:

cat -n packages/common/test/Resources.test.ts | sed -n '80,120p'

Repository: SQLoot/evolu-plan-b

Length of output: 1585


🏁 Script executed:

cat -n packages/common/src/Resources.ts | sed -n '245,310p'

Repository: SQLoot/evolu-plan-b

Length of output: 2606


🏁 Script executed:

cat -n packages/common/test/Resources.test.ts | sed -n '85,150p'

Repository: SQLoot/evolu-plan-b

Length of output: 2674


🏁 Script executed:

# Search for any rollback or cleanup logic in the addConsumer implementation
rg -n "rollback|cleanup|restore" packages/common/src/Resources.ts

Repository: SQLoot/evolu-plan-b

Length of output: 305


🏁 Script executed:

cat -n packages/common/src/Resources.ts | sed -n '477,540p'

Repository: SQLoot/evolu-plan-b

Length of output: 2464


🏁 Script executed:

# Check how rollbackAddConsumer is called in the context of addConsumer errors
cat -n packages/common/src/Resources.ts | sed -n '249,300p'

Repository: SQLoot/evolu-plan-b

Length of output: 2095


🏁 Script executed:

# Check the structure of createResources - find where rollbackAddConsumer is called and how it relates to addConsumer
cat -n packages/common/src/Resources.ts | sed -n '600,650p'

Repository: SQLoot/evolu-plan-b

Length of output: 2048


🏁 Script executed:

# Look for createResources function definition and its return type to understand the structure
cat -n packages/common/src/Resources.ts | sed -n '1,50p'

Repository: SQLoot/evolu-plan-b

Length of output: 1780


🏁 Script executed:

# Find where the synchronous addConsumer is defined
rg -n "addConsumer.*consumer.*resourceConfigs.*=>.*\{" packages/common/src/Resources.ts | head -5

Repository: SQLoot/evolu-plan-b

Length of output: 185


🏁 Script executed:

# Get a broader context around the rollback to understand the full implementation
cat -n packages/common/src/Resources.ts | sed -n '550,620p'

Repository: SQLoot/evolu-plan-b

Length of output: 2811


🏁 Script executed:

# Check what createResources returns and understand the relationship between the two versions
cat -n packages/common/src/Resources.ts | sed -n '100,180p'

Repository: SQLoot/evolu-plan-b

Length of output: 2842


🏁 Script executed:

# Search for where the async addConsumer (line 249) is used/called
rg -n "mutexByResourceId\|unabortable" packages/common/src/Resources.ts | head -20

Repository: SQLoot/evolu-plan-b

Length of output: 45


🏁 Script executed:

# Verify the relationship: are there two versions (async and legacy)? Let's look at the export/API
rg -n "export.*createAsyncResources\|export.*createLegacyResources\|export.*createResources" packages/common/src/Resources.ts

Repository: SQLoot/evolu-plan-b

Length of output: 45


🏁 Script executed:

# Check the entire structure: are both functions exported as createResources?
cat -n packages/common/src/Resources.ts | tail -100 | head -50

Repository: SQLoot/evolu-plan-b

Length of output: 1347


🏁 Script executed:

# Look at the complete async version implementation (around line 249 and beyond)
cat -n packages/common/src/Resources.ts | sed -n '168,250p'

Repository: SQLoot/evolu-plan-b

Length of output: 3045


🏁 Script executed:

# Check if there's error handling or try-catch around the addConsumer async version
cat -n packages/common/src/Resources.ts | sed -n '240,340p'

Repository: SQLoot/evolu-plan-b

Length of output: 4129


🏁 Script executed:

# Check the test file to see which createResources version it uses
cat -n packages/common/test/Resources.test.ts | sed -n '1,100p'

Repository: SQLoot/evolu-plan-b

Length of output: 3503


🏁 Script executed:

# Look for any test for the async Resources version (the one at line 249)
rg -n "createAsyncResources\|Resources<" packages/common/test/Resources.test.ts | head -20

Repository: SQLoot/evolu-plan-b

Length of output: 45


Async verze addConsumer postrádá rollback při selhání createResource.

Pokud createResource(resourceConfig) na řádku 268 selže během iterace zdroje N, zdroje úspěšně přidané v předchozích iteracích (1 až N-1) zůstanou v resourcesById bez rollbacku. Na rozdíl od synchronní createLegacyResources (která implementuje úplný rollback v try-catch bloku na řádcích 580-617), async verze chybí ochrana pro částečné selhání.

Comment on lines +321 to +337
export const testCreateWebSocket =
({
throwOnCreate = false,
}: TestCreateWebSocketOptions = {}): CreateWebSocket =>
() =>
async () => {
if (throwOnCreate) {
throw new Error("testCreateWebSocket: throwOnCreate");
}

return ok({
send: () => ok(),
getReadyState: () => "open",
isOpen: () => true,
[Symbol.dispose]: () => {},
});
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Testovací helper po dispose pořád simuluje otevřený socket.

Po zavolání [Symbol.dispose]() tenhle fake stále vrací isOpen() === true, getReadyState() === "open" a send() dál uspěje. Testy nad cleanup cestami tak můžou projít, i když by reálná implementace už byla zavřená.

💡 Návrh úpravy
 export const testCreateWebSocket =
   ({
     throwOnCreate = false,
   }: TestCreateWebSocketOptions = {}): CreateWebSocket =>
   () =>
   async () => {
     if (throwOnCreate) {
       throw new Error("testCreateWebSocket: throwOnCreate");
     }
 
+    let disposed = false;
+
     return ok({
-      send: () => ok(),
-      getReadyState: () => "open",
-      isOpen: () => true,
-      [Symbol.dispose]: () => {},
+      send: () => (disposed ? err({ type: "WebSocketSendError" }) : ok()),
+      getReadyState: () => (disposed ? "closed" : "open"),
+      isOpen: () => !disposed,
+      [Symbol.dispose]: () => {
+        disposed = true;
+      },
     });
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/WebSocket.ts` around lines 321 - 337, The
testCreateWebSocket fake currently keeps reporting an open socket even after
[Symbol.dispose] is called; modify testCreateWebSocket to maintain an internal
"closed" flag set when [Symbol.dispose]() runs and make send, isOpen and
getReadyState consult that flag: after disposal isOpen() should return false,
getReadyState() should return "closed", and send() should fail (return an
error/result indicating closed) instead of succeeding. Update the returned
object in testCreateWebSocket to use this internal state so cleanup-path tests
behave like a real closed socket.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (5)
packages/common/src/local-first/Relay.ts (2)

283-300: ⚠️ Potential issue | 🔴 Critical

deleteOwner musí běžet pod stejným mutexByOwnerId jako zápisy.

writeMessages je teď serializované per ownerId, ale deleteOwner ten samý lock obchází. Paralelní deleteOwner(ownerId) a writeMessages(ownerId, ...) tak pořád můžou smazaného ownera hned částečně obnovit, včetně usage metadat.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/local-first/Relay.ts` around lines 283 - 300, deleteOwner
currently runs its DB transaction without acquiring the per-owner lock used by
writeMessages; wrap the entire deletion logic (the deps.sqlite.transaction
callback and the call to sqliteStorageBase.deleteOwner) in the same mutex
acquisition used by writeMessages (mutexByOwnerId for the given ownerId) so
deleteOwner(ownerId) is serialized with writeMessages(ownerId,...). Acquire
mutexByOwnerId(ownerId) before starting the transaction and release it after
sqliteStorageBase.deleteOwner completes to prevent concurrent writeMessages from
recreating data.

184-190: ⚠️ Potential issue | 🟠 Major

Deduplicitní timestamp odfiltrujte ještě před výpočtem kvóty.

existingTimestampsSet pokrývá jen hodnoty už uložené v DB. Když jeden batch obsahuje stejný timestamp víckrát, všechny duplicitní položky projdou do newMessages, takže se na Lines 205-211 započítají bajty 2× a na Lines 248-253 se navýší usage, i když insert ... on conflict do nothing uloží jen jednu zprávu. To umí vrátit falešný StorageQuotaError.

Možná oprava
-              const existingTimestampsSet = new Set(
+              const seenTimestamps = new Set(
                 existingTimestampsResult.map((t) => t.toString()),
               );
               const newMessages = filterArray(
                 messagesWithTimestampBytes,
-                (m) => !existingTimestampsSet.has(m.timestamp.toString()),
+                (m) => {
+                  const timestamp = m.timestamp.toString();
+                  if (seenTimestamps.has(timestamp)) return false;
+                  seenTimestamps.add(timestamp);
+                  return true;
+                },
               );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/local-first/Relay.ts` around lines 184 - 190,
Batch-internal duplicate timestamps are not being removed before quota
calculation, causing bytes and usage to be double-counted; deduplicate
messagesWithTimestampBytes by timestamp.toString() first (e.g., produce a
batchUnique list/Set keyed by m.timestamp.toString() that keeps the first
occurrence) and then compute existingTimestampsSet and filter against it to
produce newMessages; ensure you reference the same deduplicated collection when
calculating bytes and incrementing usage (symbols: messagesWithTimestampBytes,
existingTimestampsSet, newMessages, filterArray, StorageQuotaError) so the quota
only accounts for unique timestamps in the incoming batch.
packages/common/src/local-first/Shared.ts (3)

159-161: ⚠️ Potential issue | 🟠 Major

Klíč cache musí zahrnout i appOwner.

Instance se tady znovu používá jen podle name, takže první CreateEvolu bez ownera nebo s jiným ownerem zablokuje pozdější inicializaci pro stejný name. V takovém případě se createSharedEvolu(...) znovu nespustí a transports.addConsumer(...) pro nového ownera vůbec neproběhne.

Also applies to: 183-225

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/local-first/Shared.ts` around lines 159 - 161, The
cache/mutex key currently uses only Name causing different appOwner instances to
collide; update sharedEvolusByName and sharedEvolusMutexByName to use a
composite key including appOwner (e.g. { name, appOwner } or a stringified
composite) and change the createMutexByKey generic to that composite type so
createSharedEvolu(...) and transports.addConsumer(...) are keyed per owner;
apply the same composite-key change for the other occurrences in the
createSharedEvolu block (around the 183-225 region) so each owner gets its own
mutex and map entry.

195-215: ⚠️ Potential issue | 🟠 Major

onDispose může zlikvidovat nově vytvořenou instanci.

Callback z mapy znovu načítá hodnotu jen podle message.name a bez kontroly identity ji disposne i smaže. Pokud mezitím vznikne nová instance pro stejné name, odstraní se omylem ta nová místo původní.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/local-first/Shared.ts` around lines 195 - 215, The
onDispose callback currently looks up sharedEvolusByName.get(message.name) and
disposes whatever is found, which can accidentally dispose a newly-created
instance; fix by closing over the specific instance created (e.g., capture const
created = maybeSharedEvolu when inserting) and inside
runWithSharedEvoluDeps.daemon/sharedEvolusMutexByName.withLock only call
created[Symbol.asyncDispose]() and delete from sharedEvolusByName if
sharedEvolusByName.get(message.name) === created to ensure identity match (use
the same maybeSharedEvolu reference) before disposing and removing it.

332-345: ⚠️ Potential issue | 🟠 Major

Po Dispose může leader dál ukazovat na mrtvý dbWorkerPort.

Chybí vazba mezi evoluPortId a jeho dbWorkerPort, takže dispose vyčistí jen frontu a evoluPort. Když zavřený tab držel leadera a zrovna nebyl na čele fronty, další requesty se budou posílat do zavřeného portu až do timeoutu heartbeat.

Also applies to: 543-570

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/common/src/local-first/Relay.ts`:
- Around line 283-300: deleteOwner currently runs its DB transaction without
acquiring the per-owner lock used by writeMessages; wrap the entire deletion
logic (the deps.sqlite.transaction callback and the call to
sqliteStorageBase.deleteOwner) in the same mutex acquisition used by
writeMessages (mutexByOwnerId for the given ownerId) so deleteOwner(ownerId) is
serialized with writeMessages(ownerId,...). Acquire mutexByOwnerId(ownerId)
before starting the transaction and release it after
sqliteStorageBase.deleteOwner completes to prevent concurrent writeMessages from
recreating data.
- Around line 184-190: Batch-internal duplicate timestamps are not being removed
before quota calculation, causing bytes and usage to be double-counted;
deduplicate messagesWithTimestampBytes by timestamp.toString() first (e.g.,
produce a batchUnique list/Set keyed by m.timestamp.toString() that keeps the
first occurrence) and then compute existingTimestampsSet and filter against it
to produce newMessages; ensure you reference the same deduplicated collection
when calculating bytes and incrementing usage (symbols:
messagesWithTimestampBytes, existingTimestampsSet, newMessages, filterArray,
StorageQuotaError) so the quota only accounts for unique timestamps in the
incoming batch.

In `@packages/common/src/local-first/Shared.ts`:
- Around line 159-161: The cache/mutex key currently uses only Name causing
different appOwner instances to collide; update sharedEvolusByName and
sharedEvolusMutexByName to use a composite key including appOwner (e.g. { name,
appOwner } or a stringified composite) and change the createMutexByKey generic
to that composite type so createSharedEvolu(...) and transports.addConsumer(...)
are keyed per owner; apply the same composite-key change for the other
occurrences in the createSharedEvolu block (around the 183-225 region) so each
owner gets its own mutex and map entry.
- Around line 195-215: The onDispose callback currently looks up
sharedEvolusByName.get(message.name) and disposes whatever is found, which can
accidentally dispose a newly-created instance; fix by closing over the specific
instance created (e.g., capture const created = maybeSharedEvolu when inserting)
and inside runWithSharedEvoluDeps.daemon/sharedEvolusMutexByName.withLock only
call created[Symbol.asyncDispose]() and delete from sharedEvolusByName if
sharedEvolusByName.get(message.name) === created to ensure identity match (use
the same maybeSharedEvolu reference) before disposing and removing it.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3d4936f3-82bb-4d3c-831f-36b6a91c99fa

📥 Commits

Reviewing files that changed from the base of the PR and between c32892f and 160769b.

📒 Files selected for processing (3)
  • packages/common/src/local-first/Relay.ts
  • packages/common/src/local-first/Shared.ts
  • packages/vue/src/provideEvolu.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/vue/src/provideEvolu.ts

@miccy miccy merged commit c19eaba into main Mar 11, 2026
3 of 20 checks passed
@miccy miccy deleted the dev/sync-common-v8-2026-03-11 branch March 11, 2026 08:51
@coderabbitai coderabbitai bot mentioned this pull request Mar 22, 2026
11 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Repair any bug update Update libs and deps upstream Needs cherry-pick or merge from upstream

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants