Skip to content

Sync morph type data#2192

Open
rmunn wants to merge 6 commits intodevelopfrom
feat/sync-morph-types
Open

Sync morph type data#2192
rmunn wants to merge 6 commits intodevelopfrom
feat/sync-morph-types

Conversation

@rmunn
Copy link
Copy Markdown
Contributor

@rmunn rmunn commented Mar 5, 2026

Fixes #1847 ...
...at least the first commit does.

This has grown into a large morph-type feature branch.
Most/all commits represent a sub-issue of the #1845 epic.
The sub-issues have been closed as they are merged into this branch/PR.

@rmunn rmunn self-assigned this Mar 5, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 5, 2026

📝 Walkthrough

Walkthrough

This pull request adds comprehensive MorphTypeData support to the CRDT system, including database migrations, change tracking, API implementations, sync logic, and test infrastructure. Key features include unique constraint enforcement on MorphType, immutability of MorphType post-creation, duplicate prevention, and nullable LeadingToken/TrailingToken properties.

Changes

Cohort / File(s) Summary
Database Migration
backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable*
Adds MorphTypeData table with Id (PK), MorphType (unique), Name/Abbreviation/Description (jsonb), LeadingToken/TrailingToken (nullable), SecondaryOrder, DeletedAt, SnapshotId (FK). Includes designer definitions and Up/Down migration logic.
CRDT Change Type
backend/FwLite/LcmCrdt/Changes/CreateMorphTypeDataChange.cs
New public class implementing CreateChange with properties for all MorphTypeData fields, constructor mapping, JsonConstructor support, and NewEntity override that enforces unique MorphType constraint by checking for existing entries and setting DeletedAt accordingly.
API Implementation
backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs
Updated six methods (GetAllMorphTypeData, GetMorphTypeData, CreateMorphTypeData, UpdateMorphTypeData overloads, DeleteMorphTypeData) to async patterns; implements duplicate checking, MorphType patch rejection, and change tracking via CreateMorphTypeDataChange, JsonPatchChange, and DeleteChange.
CRDT Infrastructure
backend/FwLite/LcmCrdt/LcmCrdtKernel.cs, backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs, backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs
Registers MorphTypeData as object type and change types (JsonPatchChange, DeleteChange, CreateMorphTypeDataChange); adds AllMorphTypeData queryable property; configures unique index on MorphType.
Database Model Snapshots
backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs, backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt, backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt, backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyIObjectWithIdModels.verified.txt
Updates model snapshots to reflect new MorphTypeData entity with all properties, unique indexes, and change type definitions (CreateMorphTypeDataChange, JsonPatchChange, DeleteChange).
Property Nullability & Models
backend/FwLite/MiniLcm/Models/MorphType.cs, backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateMorphTypeDataProxy.cs, backend/FwLite/MiniLcm/Models/IObjectWithId.cs
Changes LeadingToken and TrailingToken from non-nullable strings with defaults to nullable strings (string?); adds JsonDerivedType attribute for MorphTypeData polymorphic serialization.
Sync & Constraints
backend/FwLite/MiniLcm/SyncHelpers/MorphTypeDataSync.cs, backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs
Implements MorphTypeDataSync with guard preventing MorphType changes post-creation; adds patch validation in FwDataMiniLcmApi.UpdateMorphTypeData to reject MorphType modifications with InvalidOperationException.
Project Snapshot
backend/FwLite/MiniLcm/ProjectSnapshot.cs, backend/FwLite/MiniLcm/MiniLcmApiExtensions.cs
Adds AllMorphTypeData field to ProjectSnapshot record after ComplexFormTypes; updates TakeProjectSnapshot to collect MorphTypeData via GetAllMorphTypeData().ToArrayAsync().
Import & Resumable API
backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs, backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs
Adds CreateMorphTypeData method to ResumableImportApi using HasCreated pattern; adds morph type import loop in MiniLcmImport after publications; corrects log message for publications.
Test Infrastructure
backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs, backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs, backend/FwLite/LcmCrdt.Tests/SnapshotAtCommitServiceTests.cs
Adds MorphTypeData test data, expectedMorphTypes dataset, mock returns, end-to-end assertions; implements CreateMorphTypeData in UnreliableApi test wrapper; adds .WithoutStrictOrderingFor(x => x.AllMorphTypeData) to snapshot equivalence checks; increases maxTries from 20 to 30.
Test Snapshots & Verified Data
backend/FwLite/FwLiteProjectSync.Tests/sena-3-live_snapshot.verified.txt, backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt, backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs, backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs, backend/FwLite/LcmCrdt.Tests/Data/Migration*verified.txt, backend/FwLite/LcmCrdt.Tests/SnapshotDeserializationRegressionData.latest.verified.txt, backend/FwLite/FwLiteProjectSync.Tests/ProjectSnapshotSerializationTests.cs
Populates snapshots with AllMorphTypeData entries; adds MorphTypeDataChange deserialization test data; updates MigrationTests to fetch and pass AllMorphTypeData; standardizes path construction; includes 18+ morph type entries (Prefix, Suffix, Infix, etc.) with full field definitions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • #1913 — Shares ProjectSnapshot test comparison logic (AssertSnapshotsAreEquivalent with AllMorphTypeData ordering).
  • #1857 — Modifies UpdateMorphTypeDataProxy nullability and overlaps on MorphTypeData API surface.
  • #1760 — Introduces ResumableImportApi infrastructure that this PR extends with MorphTypeData support.

Suggested reviewers

  • hahn-kev
  • myieye

Poem

🐰 Hops through the database with glee,
MorphTypes now dance in harmony!
Constraints locked tight, changes denied,
A CRDT structure with rabbity pride!
Snapshots and syncs in perfect refrain—
Another great feature in LangBox's domain! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Sync morph type data' clearly and concisely describes the main objective of the changeset, which is to implement CRDT synchronization support for MorphTypeData.
Linked Issues check ✅ Passed The PR implements all objectives from #1847: models MorphTypeData in CRDT system, adds change types (CreateMorphTypeDataChange, JsonPatchChange, DeleteChange), enforces MorphType uniqueness via SQLite inline constraint, makes MorphType immutable post-creation, and prevents duplicate creation via tombstoning logic in NewEntity.
Out of Scope Changes check ✅ Passed All code changes are within scope of #1847 objectives. Changes include database schema, CRDT change handling, validation logic, test updates, and synchronization logic—all directly supporting MorphTypeData CRDT sync implementation.
Description check ✅ Passed The PR description is directly related to the changeset, referencing issue #1847 and describing the implementation of CRDT synchronization support for MorphTypeData.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sync-morph-types

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.

@github-actions github-actions bot added the 💻 FW Lite issues related to the fw lite application, not miniLcm or crdt related label Mar 5, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 5, 2026

UI unit Tests

  1 files  ±  0   59 suites  +56   32s ⏱️ +32s
176 tests +166  176 ✅ +166  0 💤 ±0  0 ❌ ±0 
245 runs  +235  245 ✅ +235  0 💤 ±0  0 ❌ ±0 

Results for commit 4028077. ± Comparison against base commit 73e4ecd.

♻️ This comment has been updated with latest results.

@argos-ci
Copy link
Copy Markdown

argos-ci bot commented Mar 5, 2026

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
default (Inspect) ⚠️ Changes detected (Review) 6 changed Apr 10, 2026, 12:17 PM

@rmunn rmunn changed the title WIP (not working yet): sync morph type data WIP: sync morph type data Mar 6, 2026
@rmunn rmunn force-pushed the feat/sync-morph-types branch from 8003c78 to 7563c10 Compare March 9, 2026 07:05
@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 9, 2026

Kind of bothered by the fact that I don't see FwDataApiDoesNotUpdateMorphType failing yet; that test is supposed to start failing once we start syncing morph type. The fact that it's not failing yet may mean I've failed to implement something.

@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 10, 2026

Okay, tests are finally green, so I can finally push the "don't create duplicate MorphTypes" logic and make sure it doesn't break the tests.

@rmunn rmunn changed the title WIP: sync morph type data Sync morph type data Mar 10, 2026
@rmunn rmunn marked this pull request as ready for review March 10, 2026 07:46
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: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/FwLite/LcmCrdt/LcmCrdtKernel.cs (1)

254-260: ⚠️ Potential issue | 🟠 Major

Use a custom patch change for MorphTypeData that prevents MorphType field updates.

JsonPatchChange<MorphTypeData> allows arbitrary patches to all fields, including MorphType. However, CreateMorphTypeDataChange enforces a unique constraint on MorphType only at creation time. Updates via generic patch can bypass this, allowing one record to be patched to duplicate another's MorphType, and can change MorphType after creation in violation of immutability. Create a dedicated change class for updates that rejects /MorphType operations and only allows mutations to Name, Abbreviation, Description, LeadingToken, TrailingToken, and SecondaryOrder.

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

In `@backend/FwLite/LcmCrdt/LcmCrdtKernel.cs` around lines 254 - 260, Replace the
generic JsonPatchChange<MorphTypeData> with a custom patch-change implementation
(e.g., MorphTypeDataPatchChange) and register that instead in
config.ChangeTypeListBuilder; implement MorphTypeDataPatchChange to accept only
JSON-Patch operations addressing Name, Abbreviation, Description, LeadingToken,
TrailingToken, and SecondaryOrder, explicitly reject any operation whose path
targets /MorphType (return validation/error), and reuse
CreateMorphTypeDataChange for creation-only logic so updates cannot bypass the
uniqueness/immutability constraint on MorphType.
🧹 Nitpick comments (2)
backend/FwLite/FwLiteProjectSync.Tests/Snapshots/sena-3_snapshot.2025-09-30.verified.txt (1)

124315-124315: Avoid leaving the gold-standard snapshot with zero morph-type coverage.

With AllMorphTypeData empty here, the Sena3 snapshot still won’t catch regressions in MorphTypeData extraction/serialization/round-tripping. Since this PR’s core behavior is morph type sync, please consider backing it with at least one fixture or assertion that produces a non-empty AllMorphTypeData entry.

Based on learnings: The Sena3SyncTests class uses a real FwData project and represents the gold standard for sync testing; when fixing a sync bug, reproduce it with a test.

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

In
`@backend/FwLite/FwLiteProjectSync.Tests/Snapshots/sena-3_snapshot.2025-09-30.verified.txt`
at line 124315, The snapshot currently has an empty "AllMorphTypeData" which
will let regressions in morph-type extraction/serialization slip; update the
Sena3SyncTests setup to produce and persist at least one MorphTypeData entry so
the snapshot contains a non-empty AllMorphTypeData. Specifically, in the
Sena3SyncTests test fixture or test data builder that seeds the real FwData
project, create a representative MorphType (or reuse an existing one) and ensure
the sync/export path populates AllMorphTypeData before the snapshot is written,
then re-run the test to regenerate the gold snapshot so the file shows a
non-empty AllMorphTypeData array.
backend/FwLite/MiniLcm/MiniLcmApiExtensions.cs (1)

20-20: Make AllMorphTypeData ordering deterministic at snapshot creation.

Two test helpers in this PR now had to opt out of strict ordering for AllMorphTypeData. Sorting here by the stable morph-type key would keep ProjectSnapshot deterministic instead of teaching every comparison to ignore order.

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

In `@backend/FwLite/MiniLcm/MiniLcmApiExtensions.cs` at line 20, The call to
GetAllMorphTypeData() should produce a deterministic ordering for
ProjectSnapshot; update the snapshot creation path in MiniLcmApiExtensions by
ordering the result of GetAllMorphTypeData() by the stable morph-type key (the
property used as the morph-type identifier) before materializing the array
(i.e., insert an OrderBy on the morph-type key prior to ToArrayAsync()); this
ensures AllMorphTypeData is consistently ordered and removes the need for tests
to ignore ordering.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs`:
- Around line 83-94: The test currently doesn't validate resumable morph-type
imports because the mock source data from GetAllMorphTypeData isn't asserted
after import and UnreliableApi isn't configured to inject failures for
morph-type writes; update the ResumableTests.cs test to (1) assert that the new
MorphTypeData appears in the destination ProjectSnapshot after running
CrdtFwdataProjectSyncService.Sync(), (2) route morph-type create calls
(CreateMorphTypeData) through UnreliableApi.MaybeThrowRandom so failures are
injected during writes, and (3) optionally run the sync in a dry-run via
DryRunMiniLcmApi to inspect expected changes while debugging the resume behavior
in CrdtFwdataProjectSyncService.Sync and ProjectSnapshot handling.

In
`@backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt`:
- Around line 826-827: The test corpus only contains non-null "LeadingToken" and
"TrailingToken" values, so add a new regression entry in
ChangeDeserializationRegressionData.latest.verified.txt that exercises the
nullable shape by including at least one object where "LeadingToken": null
and/or "TrailingToken": null (preserving other required fields), so
deserialization of the new nullable properties is covered; locate the JSON block
containing "LeadingToken" and "TrailingToken" and add an additional case with
the null token(s) and appropriate context strings to verify parsing.

In
`@backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt`:
- Around line 43-46: The UpdateMorphTypeData endpoint currently accepts
JsonPatchChange<MorphTypeData> without field-level checks, allowing modification
of the immutable MorphType field; add validation in UpdateMorphTypeData(Guid id,
UpdateObjectInput<MorphTypeData> update) that inspects the incoming patch
operations and rejects any operation whose path targets "MorphType" (or
equivalent casing) before applying changes, or alternatively restrict the
generic JsonPatchChange<MorphTypeData> registration so it only permits the exact
set of patchable properties used by MorphTypeDataSync (i.e., exclude
"MorphType"); ensure you use the existing JsonPatchValidator logic as a base and
extend it to check for literal field paths (not only array/index ops) and return
a validation error when a forbidden path is present.

In `@backend/FwLite/LcmCrdt/Changes/CreateMorphTypeDataChange.cs`:
- Around line 21-32: The current CreateMorphTypeDataChange returns a new
MorphTypeData even when an existing row with the same MorphType exists, which
will violate the new unique index; change the behavior in
CreateMorphTypeDataChange so that when alreadyExists is true you do not insert a
new row but instead locate the existing MorphTypeData (query by MorphType) and
update/return that entity (e.g., set its DeletedAt to commit.DateTime or simply
return the existing instance) rather than constructing a new MorphTypeData with
the same MorphType; update the code paths that reference MorphTypeData creation
to use the located entity instead.

In `@backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs`:
- Around line 389-392: The UpdateMorphTypeData method currently accepts any
JsonPatchChange<MorphTypeData>, allowing clients to modify the immutable
MorphType/Kind; detect and reject any patch operations that target the MorphType
(e.g. op.Path equals "/MorphType" or "/Kind", case-insensitive) before calling
AddChange, and return/throw a BadRequest/InvalidOperation indicating MorphType
is immutable. Alternatively, replace the direct JsonPatchChange<MorphTypeData>
usage here with a dedicated change class that only applies allowed mutable
fields and use that class in AddChange; update callers to construct the
dedicated change when necessary. Ensure you reference UpdateMorphTypeData,
JsonPatchChange<MorphTypeData>, AddChange and GetMorphTypeData when making the
change.
- Around line 367-386: In CreateMorphTypeData, the final reload uses the
caller-supplied Id which can fail if a concurrent writer created the same
MorphType after the initial pre-check; change the final fetch to reload the
canonical row by MorphType (e.g. use SingleAsync or FirstAsync on
repo.AllMorphTypeData where c.MorphType == morphTypeData.MorphType) so the
method returns the surviving record after duplicate suppression instead of
assuming the original Id is present.

---

Outside diff comments:
In `@backend/FwLite/LcmCrdt/LcmCrdtKernel.cs`:
- Around line 254-260: Replace the generic JsonPatchChange<MorphTypeData> with a
custom patch-change implementation (e.g., MorphTypeDataPatchChange) and register
that instead in config.ChangeTypeListBuilder; implement MorphTypeDataPatchChange
to accept only JSON-Patch operations addressing Name, Abbreviation, Description,
LeadingToken, TrailingToken, and SecondaryOrder, explicitly reject any operation
whose path targets /MorphType (return validation/error), and reuse
CreateMorphTypeDataChange for creation-only logic so updates cannot bypass the
uniqueness/immutability constraint on MorphType.

---

Nitpick comments:
In
`@backend/FwLite/FwLiteProjectSync.Tests/Snapshots/sena-3_snapshot.2025-09-30.verified.txt`:
- Line 124315: The snapshot currently has an empty "AllMorphTypeData" which will
let regressions in morph-type extraction/serialization slip; update the
Sena3SyncTests setup to produce and persist at least one MorphTypeData entry so
the snapshot contains a non-empty AllMorphTypeData. Specifically, in the
Sena3SyncTests test fixture or test data builder that seeds the real FwData
project, create a representative MorphType (or reuse an existing one) and ensure
the sync/export path populates AllMorphTypeData before the snapshot is written,
then re-run the test to regenerate the gold snapshot so the file shows a
non-empty AllMorphTypeData array.

In `@backend/FwLite/MiniLcm/MiniLcmApiExtensions.cs`:
- Line 20: The call to GetAllMorphTypeData() should produce a deterministic
ordering for ProjectSnapshot; update the snapshot creation path in
MiniLcmApiExtensions by ordering the result of GetAllMorphTypeData() by the
stable morph-type key (the property used as the morph-type identifier) before
materializing the array (i.e., insert an OrderBy on the morph-type key prior to
ToArrayAsync()); this ensures AllMorphTypeData is consistently ordered and
removes the need for tests to ignore ordering.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4371c2dd-bf66-4825-9e2a-ec8409562a53

📥 Commits

Reviewing files that changed from the base of the PR and between 6a341ca and 4ffc8e3.

📒 Files selected for processing (33)
  • backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateMorphTypeDataProxy.cs
  • backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs
  • backend/FwLite/FwLiteProjectSync.Tests/Snapshots/sena-3_snapshot.2025-09-30.verified.txt
  • backend/FwLite/FwLiteProjectSync.Tests/Snapshots/sena-3_snapshot.2025-09-31.verified.txt
  • backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs
  • backend/FwLite/FwLiteProjectSync.Tests/sena-3-live.verified.sqlite
  • backend/FwLite/FwLiteProjectSync.Tests/sena-3-live_snapshot.verified.txt
  • backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs
  • backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs
  • backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt
  • backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs
  • backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs
  • backend/FwLite/LcmCrdt.Tests/Data/MigrationTests_FromScriptedDb.v1.ProjectSnapshot.verified.txt
  • backend/FwLite/LcmCrdt.Tests/Data/MigrationTests_FromScriptedDb.v2.ProjectSnapshot.verified.txt
  • backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt
  • backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt
  • backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt
  • backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyIObjectWithIdModels.verified.txt
  • backend/FwLite/LcmCrdt.Tests/SnapshotAtCommitServiceTests.cs
  • backend/FwLite/LcmCrdt/Changes/CreateMorphTypeDataChange.cs
  • backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs
  • backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs
  • backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs
  • backend/FwLite/LcmCrdt/LcmCrdtKernel.cs
  • backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.Designer.cs
  • backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.cs
  • backend/FwLite/LcmCrdt/Migrations/20260310065457_UniqueIndexOnMorphType.Designer.cs
  • backend/FwLite/LcmCrdt/Migrations/20260310065457_UniqueIndexOnMorphType.cs
  • backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs
  • backend/FwLite/MiniLcm/MiniLcmApiExtensions.cs
  • backend/FwLite/MiniLcm/Models/IObjectWithId.cs
  • backend/FwLite/MiniLcm/Models/MorphType.cs
  • backend/FwLite/MiniLcm/ProjectSnapshot.cs

@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 10, 2026

@CodeRabbit There is one potential issue that you did not mention in your review. The MorphTypeData table in the Sqlite database will have a unique index on the MorphType column (which corresponds to the MorphType enum in C#). In the NewEntity method of the CreateMorphTypeDataChange class, we check whether there is an existing MorphTypeData object with the same MorphType value, and if there is already an existing object, then the new MorphTypeData object should not be created. But because we're in the NewEntity method, which is not allowed to return null (if NewEntity could return null it would break many other parts of the CRDT implementation), we must return something, and so we return an already-deleted object (with the DeletedAt column being populated).

However, in Sqlite there is no way to add a WHERE clause to a unique constraint: you cannot say "the MorphType column must be unique only among rows where DeletedAt is null". And so it is possible for CreateMorphTypeDataChange.NewEntity to return a MorphType object that will violate the unique constraint, even though the code attempts to avoid that scenario.

This is unlikely to happen since the CrdtMiniLcmApi class attempts to prevent the creation of duplicates. But there still exists a possibility that a user (let's call him user A) might be offline for a while, and try to create a new MorphType object while offline. This would create a CreateMorphTypeDataChange instance, which would be stored untll that user comes back online. Meanwhile, a different user (let's call him user B) is online, so when he creates a new MorphType object (with the same MorphType value as the one that user A created), his change goes through right away. Later, user A comes back online, so all the CRDT changes that he created while he was offline (including the CreateMorphTypeDataChange) are transmitted to other projects. Now there are two CreateMorphTypeDataChange objects trying to create two MorphTypeData objects with the same MorphType value, and the guard in the API cannot protect against this scenario. This will cause a DB constraint violation, and our CRDT implementation does not handle DB constraint violations: if a constraint is violated, the CRDT sync process will stop and block any further syncs until a developer is able to look at the project and remove the duplicated Change object.

The solution I plan to implement is to make the Sqlite constraint use ON CONFLICT IGNORE, which will mean that the conflicting MorphTypeData row will be ignored (skipped), but the rest of the database transaction will proceed. This will mean that creating MorphTypeData objects uses a "first one wins" resolution strategy. This is fine because in FieldWorks Classic, new morph types cannot be created (and we plan to have the same restriction in FieldWorks Lite), so there will never be any actual chance of new user-created MorphTypeData objects conflicting with each other. But the theoretical possibility of a DB constraint violation on this unique constraint still exists, so the best strategy for our use case is to use ON CONFLICT IGNORE on the unique constraint for MorphType.

Edit: Actually, I see you did mention this. Good.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 10, 2026

Tip

For best results, initiate chat on the files or code changes.

@rmunn: Thanks for the detailed explanation — glad I did catch this in the review! Your proposed ON CONFLICT IGNORE strategy is the right call for this "first one wins" CRDT scenario, especially given that users won't be able to create new morph types in practice.

One important implementation detail worth flagging: SQLite's ON CONFLICT clause is allowed on UNIQUE, NOT NULL, and PRIMARY KEY constraints only, and is not allowed on CHECK and FOREIGN KEY constraints. However, the critical nuance is that the ON CONFLICT clause applies to constraints defined inline in CREATE TABLE, not to separately-created indexes via CREATE UNIQUE INDEX. So the current migration that creates IX_MorphTypeData_MorphType as a separate index cannot carry the ON CONFLICT IGNORE directive.

To implement this, you'd likely need to either:

  1. Rebuild the table with an inline UNIQUE ... ON CONFLICT IGNORE constraint: Use a raw SQL migration (via migrationBuilder.Sql(...)) to drop the MorphTypeData table and recreate it with the constraint inline:

    CREATE TABLE MorphTypeData_new (
        Id TEXT NOT NULL PRIMARY KEY,
        MorphType INTEGER NOT NULL UNIQUE ON CONFLICT IGNORE,
        ...
    );
    INSERT INTO MorphTypeData_new SELECT * FROM MorphTypeData;
    DROP TABLE MorphTypeData;
    ALTER TABLE MorphTypeData_new RENAME TO MorphTypeData;
  2. Use an EF Core SaveChanges interceptor that emits INSERT OR IGNORE INTO MorphTypeData ... instead of the standard INSERT INTO ... for MorphTypeData rows, leaving the regular CREATE UNIQUE INDEX in place.

Option 1 is cleaner from the schema perspective since the conflict resolution policy is baked into the schema itself, but it does require a table-rebuild migration (which SQLite typically requires for schema changes anyway). Option 2 is more surgical but adds EF Core-level complexity.

Either way, it'd be worth adding a comment in the migration or the CreateMorphTypeDataChange class explaining the "first one wins" reasoning, so future developers understand why this constraint is intentionally lenient.


✏️ Learnings added
Learnt from: rmunn
URL: https://github.com/sillsdev/languageforge-lexbox/pull/2192

Timestamp: 2026-03-10T08:33:24.145Z
Learning: In backend/FwLite/LcmCrdt, the MorphTypeData table has a unique index on the MorphType column (corresponding to the MorphType C# enum). The intended conflict resolution is "first one wins" using SQLite's ON CONFLICT IGNORE, because: (1) CRDT's NewEntity() cannot return null, so duplicate CreateMorphTypeDataChange objects (from concurrent offline users) result in a second row with DeletedAt set, potentially violating the unique constraint; (2) In practice, users cannot create new morph types in FieldWorks Classic or Lite. SQLite's ON CONFLICT IGNORE clause can only be applied to constraints defined inline in CREATE TABLE (not to separately-created indexes via CREATE UNIQUE INDEX), so implementing this requires either rebuilding the table with an inline UNIQUE ... ON CONFLICT IGNORE constraint or using an EF Core SaveChanges interceptor with INSERT OR IGNORE.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/MiniLcm/SyncHelpers/*.cs : When adding new fields to MiniLcm models, add diff logic for the new field in the appropriate sync helper file (e.g., EntrySync.cs for Entry fields). Sync bugs here cause data loss.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/FwLiteProjectSync.Tests/**/*.cs : When fixing a sync bug, reproduce it with a test. Use `DryRunMiniLcmApi` to see what changes would be made. Debug through `CrdtFwdataProjectSyncService.Sync()` and verify `ProjectSnapshot` handling.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/MiniLcm/Models/Entry.cs : Entry is a core model that affects all downstream systems. When adding fields to Entry, ensure proper implementation in Copy(), GetReferences(), RemoveReference(), CRDT mirroring, change handling, sync logic, FwData mapping, and comprehensive test coverage across all three test projects

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs : ProjectSnapshot must be regenerated from CRDT after sync completes. The order of sync operations is critical: WritingSystems → Publications → PartsOfSpeech → SemanticDomains → ComplexFormTypes → Entries (entries sync last as they depend on others). Complex forms sync is two-phase: SyncWithoutComplexFormsAndComponents first, then SyncComplexFormsAndComponents. Ensure `fwdataApi.Save()` is called after changes.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 10, 2026

Tip

For best results, initiate chat on the files or code changes.

@rmunn — thanks for the detailed write-up! Your analysis of the race condition is spot-on and the ON CONFLICT IGNORE strategy is a sensible choice given that morph type creation will be practically locked down in FW Lite anyway.

One important implementation detail worth flagging: SQLite's ON CONFLICT IGNORE clause is only valid in a CREATE TABLE column/table constraint, not in a standalone CREATE UNIQUE INDEX statement. The current UniqueIndexOnMorphType migration generates a standalone CREATE UNIQUE INDEX, which cannot carry an ON CONFLICT clause. For example, this is not valid SQLite syntax:

-- ❌ Not valid in SQLite
CREATE UNIQUE INDEX IX_MorphTypeData_MorphType ON MorphTypeData (MorphType) ON CONFLICT IGNORE;

To use ON CONFLICT IGNORE, the constraint needs to be part of the CREATE TABLE DDL:

-- ✅ Valid
CREATE TABLE MorphTypeData (
    ...
    MorphType INTEGER NOT NULL UNIQUE ON CONFLICT IGNORE,
    ...
);

Since EF Core doesn't support this natively for SQLite, you have a couple of options for the migration:

Option A – Raw SQL table recreation (SQLite style)

Because SQLite doesn't support ALTER TABLE ... ADD CONSTRAINT, you'd need to drop and recreate the table with the conflict clause baked in. EF Core SQLite migrations already do this for some schema changes (the "rebuild table" pattern). You could write a raw migration:

protected override void Up(MigrationBuilder migrationBuilder)
{
    // Drop the standalone unique index added in the previous migration
    migrationBuilder.DropIndex(
        name: "IX_MorphTypeData_MorphType",
        table: "MorphTypeData");

    // Recreate it as a table-level constraint with ON CONFLICT IGNORE
    // This requires rebuilding the table in SQLite
    migrationBuilder.Sql(@"
        CREATE TABLE MorphTypeData_new (
            Id TEXT NOT NULL,
            MorphType INTEGER NOT NULL UNIQUE ON CONFLICT IGNORE,
            Name TEXT NOT NULL,
            Abbreviation TEXT NOT NULL,
            Description TEXT NOT NULL,
            LeadingToken TEXT,
            TrailingToken TEXT,
            SecondaryOrder INTEGER NOT NULL,
            DeletedAt TEXT,
            SnapshotId TEXT,
            PRIMARY KEY (Id),
            FOREIGN KEY (SnapshotId) REFERENCES Snapshots(Id) ON DELETE SET NULL
        );
        INSERT INTO MorphTypeData_new SELECT * FROM MorphTypeData;
        DROP TABLE MorphTypeData;
        ALTER TABLE MorphTypeData_new RENAME TO MorphTypeData;
        CREATE UNIQUE INDEX IX_MorphTypeData_SnapshotId ON MorphTypeData (SnapshotId);
    ");
}

Option B – Catch and ignore at the application level

Instead of changing the DB schema, catch SqliteException with error code SQLITE_CONSTRAINT_UNIQUE (19) specifically for the MorphTypeData table in the CRDT sync path and swallow it. This is messier but avoids the table-rebuild migration.

Option C – Defer to a follow-up issue

Since FieldWorks Classic doesn't allow creating new morph types, and FW Lite plans the same restriction, the practical risk is extremely low. This could be tracked as a follow-up hardening issue rather than blocking this PR.

Would you like me to open a follow-up issue to track the ON CONFLICT IGNORE hardening, or do you plan to address it directly in this PR?


✏️ Learnings added
Learnt from: rmunn
URL: https://github.com/sillsdev/languageforge-lexbox/pull/2192

Timestamp: 2026-03-10T08:34:03.249Z
Learning: In the LcmCrdt SQLite database for sillsdev/languageforge-lexbox, `ON CONFLICT IGNORE` for a unique constraint on MorphTypeData.MorphType cannot be implemented via a standalone `CREATE UNIQUE INDEX` statement (SQLite doesn't support ON CONFLICT clauses on CREATE INDEX). It must be a table-level constraint in the CREATE TABLE DDL, requiring a table-rebuild migration. The practical risk is low because morph type creation is restricted in both FieldWorks Classic and FW Lite, but the theoretical CRDT offline-sync race condition exists.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/MiniLcm/SyncHelpers/*.cs : When adding new fields to MiniLcm models, add diff logic for the new field in the appropriate sync helper file (e.g., EntrySync.cs for Entry fields). Sync bugs here cause data loss.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/FwLiteProjectSync.Tests/**/*.cs : When fixing a sync bug, reproduce it with a test. Use `DryRunMiniLcmApi` to see what changes would be made. Debug through `CrdtFwdataProjectSyncService.Sync()` and verify `ProjectSnapshot` handling.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/MiniLcm/Models/Entry.cs : Entry is a core model that affects all downstream systems. When adding fields to Entry, ensure proper implementation in Copy(), GetReferences(), RemoveReference(), CRDT mirroring, change handling, sync logic, FwData mapping, and comprehensive test coverage across all three test projects

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs : ProjectSnapshot must be regenerated from CRDT after sync completes. The order of sync operations is critical: WritingSystems → Publications → PartsOfSpeech → SemanticDomains → ComplexFormTypes → Entries (entries sync last as they depend on others). Complex forms sync is two-phase: SyncWithoutComplexFormsAndComponents first, then SyncComplexFormsAndComponents. Ensure `fwdataApi.Save()` is called after changes.

@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 10, 2026

There is another option for the ON CONFLICT IGNORE unique constraint. Because this PR, which has not been merged yet, contains the DB migration that will create the table in the first place, it is still possible to rewrite the PR to drop the commits creating the previous version of the DB migration, and create a new DB migration that will create the table with the ON CONFLICT IGNORE constraint already existing.

@CodeRabbit Please provide sample code for creating the EF Core migration in a database environment where the table does not already exist, using ON CONFLICT IGNORE from the beginning.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 10, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get the existing migration file to see the exact table structure
cat backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.cs

Length of output: 2212


@rmunn: Great idea to consolidate this into the initial table-creation migration before the PR lands. Here's the approach:

Since EF Core's CreateTable() API has no way to emit SQLite's ON CONFLICT clause, replace that call with migrationBuilder.Sql() so you control the DDL exactly. To keep EF's model snapshot consistent with HasIndex(m => m.MorphType).IsUnique() in LcmCrdtDbContext.cs, also emit a named CREATE UNIQUE INDEX separately — SQLite allows both the inline UNIQUE ON CONFLICT IGNORE constraint and a named unique index on the same column. When a duplicate INSERT arrives, the inline constraint's IGNORE fires first, so the named index is never violated.

Rewritten AddMorphTypeDataTable.cs (replaces both the old AddMorphTypeDataTable and the separate UniqueIndexOnMorphType migrations):

using System;
using Microsoft.EntityFrameworkCore.Migrations;

`#nullable` disable

namespace LcmCrdt.Migrations
{
    /// <inheritdoc />
    public partial class AddMorphTypeDataTable : Migration
    {
        /// <inheritdoc />
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            // EF Core's CreateTable() API does not support SQLite's ON CONFLICT clause,
            // so we use raw SQL to embed UNIQUE ON CONFLICT IGNORE on the MorphType column.
            //
            // "First one wins" is the correct CRDT conflict-resolution strategy here:
            // if two offline users each create a MorphTypeData with the same MorphType enum
            // value, the second INSERT is silently ignored rather than crashing the sync.
            // In practice this race is extremely unlikely because neither FieldWorks Classic
            // nor FW Lite allows users to create new morph types, but we harden the schema
            // regardless to prevent a stuck-sync situation.
            migrationBuilder.Sql(@"
                CREATE TABLE MorphTypeData (
                    Id             TEXT    NOT NULL,
                    MorphType      INTEGER NOT NULL UNIQUE ON CONFLICT IGNORE,
                    Name           TEXT    NOT NULL,
                    Abbreviation   TEXT    NOT NULL,
                    Description    TEXT    NOT NULL,
                    LeadingToken   TEXT,
                    TrailingToken  TEXT,
                    SecondaryOrder INTEGER NOT NULL,
                    DeletedAt      TEXT,
                    SnapshotId     TEXT,
                    CONSTRAINT PK_MorphTypeData PRIMARY KEY (Id),
                    CONSTRAINT FK_MorphTypeData_Snapshots_SnapshotId
                        FOREIGN KEY (SnapshotId) REFERENCES Snapshots (Id) ON DELETE SET NULL
                );
            ");

            // Create a *named* unique index on MorphType so that EF Core's model snapshot
            // (HasIndex(m => m.MorphType).IsUnique()) stays consistent with the database.
            // SQLite allows this alongside the inline UNIQUE constraint above; the inline
            // ON CONFLICT IGNORE fires first on any duplicate insert, so this named index
            // is never violated in practice.
            migrationBuilder.Sql(
                "CREATE UNIQUE INDEX IX_MorphTypeData_MorphType ON MorphTypeData (MorphType);");

            // SnapshotId does not need conflict-resolution handling, so the EF Core API is fine.
            migrationBuilder.CreateIndex(
                name: "IX_MorphTypeData_SnapshotId",
                table: "MorphTypeData",
                column: "SnapshotId",
                unique: true);
        }

        /// <inheritdoc />
        protected override void Down(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.DropTable(name: "MorphTypeData");
        }
    }
}

Key points:

Concern Approach
ON CONFLICT IGNORE Inline UNIQUE ON CONFLICT IGNORE in the CREATE TABLE DDL
EF model consistency Separate CREATE UNIQUE INDEX IX_MorphTypeData_MorphType keeps the model snapshot happy
SnapshotId index Still uses migrationBuilder.CreateIndex() — no conflict clause needed
UniqueIndexOnMorphType migration Can be deleted entirely; this single migration covers both
Designer / model snapshot Should be regenerated with dotnet ef migrations add or manually updated to reflect the single combined migration; the HasIndex(m => m.MorphType).IsUnique() config in LcmCrdtDbContext.cs can stay as-is

The column types (TEXT, INTEGER, jsonb) match the original migration exactly, and the FK ON DELETE SET NULL is preserved as a named table constraint.


✏️ Learnings added
Learnt from: rmunn
URL: https://github.com/sillsdev/languageforge-lexbox/pull/2192

Timestamp: 2026-03-10T08:50:35.860Z
Learning: In sillsdev/languageforge-lexbox LcmCrdt, when a SQLite table needs `UNIQUE ON CONFLICT IGNORE` on a column (e.g., MorphTypeData.MorphType), the EF Core migration must use `migrationBuilder.Sql()` for the CREATE TABLE DDL (since EF Core's CreateTable() API doesn't support ON CONFLICT clauses). A separate named `CREATE UNIQUE INDEX` should also be emitted via `migrationBuilder.Sql()` so EF Core's model snapshot stays consistent with `HasIndex(...).IsUnique()` configuration. SQLite allows both the inline constraint and the named index on the same column; the inline ON CONFLICT IGNORE fires first on duplicate inserts.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 0
File: :0-0
Timestamp: 2026-03-10T08:33:24.145Z
Learning: In backend/FwLite/LcmCrdt, the MorphTypeData table has a unique index on the MorphType column (corresponding to the MorphType C# enum). The intended conflict resolution is "first one wins" using SQLite's ON CONFLICT IGNORE, because: (1) CRDT's NewEntity() cannot return null, so duplicate CreateMorphTypeDataChange objects (from concurrent offline users) result in a second row with DeletedAt set, potentially violating the unique constraint; (2) In practice, users cannot create new morph types in FieldWorks Classic or Lite. SQLite's ON CONFLICT IGNORE clause can only be applied to constraints defined inline in CREATE TABLE (not to separately-created indexes via CREATE UNIQUE INDEX), so implementing this requires either rebuilding the table with an inline UNIQUE ... ON CONFLICT IGNORE constraint or using an EF Core SaveChanges interceptor with INSERT OR IGNORE.

Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 0
File: :0-0
Timestamp: 2026-03-10T08:34:03.249Z
Learning: In the LcmCrdt SQLite database for sillsdev/languageforge-lexbox, `ON CONFLICT IGNORE` for a unique constraint on MorphTypeData.MorphType cannot be implemented via a standalone `CREATE UNIQUE INDEX` statement (SQLite doesn't support ON CONFLICT clauses on CREATE INDEX). It must be a table-level constraint in the CREATE TABLE DDL, requiring a table-rebuild migration. The practical risk is low because morph type creation is restricted in both FieldWorks Classic and FW Lite, but the theoretical CRDT offline-sync race condition exists.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/MiniLcm/SyncHelpers/*.cs : When adding new fields to MiniLcm models, add diff logic for the new field in the appropriate sync helper file (e.g., EntrySync.cs for Entry fields). Sync bugs here cause data loss.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/FwLiteProjectSync.Tests/**/*.cs : When fixing a sync bug, reproduce it with a test. Use `DryRunMiniLcmApi` to see what changes would be made. Debug through `CrdtFwdataProjectSyncService.Sync()` and verify `ProjectSnapshot` handling.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs : ProjectSnapshot must be regenerated from CRDT after sync completes. The order of sync operations is critical: WritingSystems → Publications → PartsOfSpeech → SemanticDomains → ComplexFormTypes → Entries (entries sync last as they depend on others). Complex forms sync is two-phase: SyncWithoutComplexFormsAndComponents first, then SyncComplexFormsAndComponents. Ensure `fwdataApi.Save()` is called after changes.

@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 11, 2026

@CodeRabbit review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 11, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 11, 2026

@CodeRabbit fullreview

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 11, 2026

@rmunn: Triggering a full review of the PR now.


🧠 Learnings used
Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 0
File: :0-0
Timestamp: 2026-03-10T08:33:24.170Z
Learning: In backend/FwLite/LcmCrdt, the MorphTypeData table has a unique index on the MorphType column (corresponding to the MorphType C# enum). The intended conflict resolution is "first one wins" using SQLite's ON CONFLICT IGNORE, because: (1) CRDT's NewEntity() cannot return null, so duplicate CreateMorphTypeDataChange objects (from concurrent offline users) result in a second row with DeletedAt set, potentially violating the unique constraint; (2) In practice, users cannot create new morph types in FieldWorks Classic or Lite. SQLite's ON CONFLICT IGNORE clause can only be applied to constraints defined inline in CREATE TABLE (not to separately-created indexes via CREATE UNIQUE INDEX), so implementing this requires either rebuilding the table with an inline UNIQUE ... ON CONFLICT IGNORE constraint or using an EF Core SaveChanges interceptor with INSERT OR IGNORE.

Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 0
File: :0-0
Timestamp: 2026-03-10T08:50:35.872Z
Learning: In sillsdev/languageforge-lexbox LcmCrdt, when a SQLite table needs `UNIQUE ON CONFLICT IGNORE` on a column (e.g., MorphTypeData.MorphType), the EF Core migration must use `migrationBuilder.Sql()` for the CREATE TABLE DDL (since EF Core's CreateTable() API doesn't support ON CONFLICT clauses). A separate named `CREATE UNIQUE INDEX` should also be emitted via `migrationBuilder.Sql()` so EF Core's model snapshot stays consistent with `HasIndex(...).IsUnique()` configuration. SQLite allows both the inline constraint and the named index on the same column; the inline ON CONFLICT IGNORE fires first on duplicate inserts.

Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 2192
File: backend/FwLite/LcmCrdt/Changes/CreateMorphTypeDataChange.cs:21-32
Timestamp: 2026-03-11T02:23:21.607Z
Learning: In sillsdev/languageforge-lexbox LcmCrdt, the MorphTypeData table's UNIQUE ON CONFLICT IGNORE constraint on the MorphType column is implemented in migration 20260306045826_AddMorphTypeDataTable.cs using migrationBuilder.Sql() with a hand-written CREATE TABLE DDL (since EF Core's CreateTable() API doesn't support ON CONFLICT clauses). A separate migrationBuilder.CreateIndex() for IX_MorphTypeData_MorphType is also emitted so EF Core's model snapshot stays consistent with HasIndex(...).IsUnique() configuration. A separate migration 20260310065457_UniqueIndexOnMorphType (which used CREATE UNIQUE INDEX without ON CONFLICT support) was deleted as part of this consolidation.

Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 0
File: :0-0
Timestamp: 2026-03-10T08:34:03.262Z
Learning: In the LcmCrdt SQLite database for sillsdev/languageforge-lexbox, `ON CONFLICT IGNORE` for a unique constraint on MorphTypeData.MorphType cannot be implemented via a standalone `CREATE UNIQUE INDEX` statement (SQLite doesn't support ON CONFLICT clauses on CREATE INDEX). It must be a table-level constraint in the CREATE TABLE DDL, requiring a table-rebuild migration. The practical risk is low because morph type creation is restricted in both FieldWorks Classic and FW Lite, but the theoretical CRDT offline-sync race condition exists.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/MiniLcm/SyncHelpers/*.cs : When adding new fields to MiniLcm models, add diff logic for the new field in the appropriate sync helper file (e.g., EntrySync.cs for Entry fields). Sync bugs here cause data loss.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/MiniLcm/Models/Entry.cs : Entry is a core model that affects all downstream systems. When adding fields to Entry, ensure proper implementation in Copy(), GetReferences(), RemoveReference(), CRDT mirroring, change handling, sync logic, FwData mapping, and comprehensive test coverage across all three test projects

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/FwLiteProjectSync.Tests/**/*.cs : When fixing a sync bug, reproduce it with a test. Use `DryRunMiniLcmApi` to see what changes would be made. Debug through `CrdtFwdataProjectSyncService.Sync()` and verify `ProjectSnapshot` handling.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/*.cs : Before making changes to MiniLcm, LcmCrdt, FwDataMiniLcmBridge, or FwLiteProjectSync: read the relevant section thoroughly, understand the sync flow end-to-end, run the full test suite with `dotnet test FwLiteOnly.slnf`, and test with real FwData projects

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/LcmCrdt/Objects/*.cs : When adding a new field/property to MiniLcm models, mirror the property exactly in the corresponding LcmCrdt entity class

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs : ProjectSnapshot must be regenerated from CRDT after sync completes. The order of sync operations is critical: WritingSystems → Publications → PartsOfSpeech → SemanticDomains → ComplexFormTypes → Entries (entries sync last as they depend on others). Complex forms sync is two-phase: SyncWithoutComplexFormsAndComponents first, then SyncComplexFormsAndComponents. Ensure `fwdataApi.Save()` is called after changes.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Before making changes to MiniLcm, LcmCrdt, FwDataMiniLcmBridge, or FwLiteProjectSync: read the relevant section thoroughly, understand the sync flow end-to-end, run the full test suite with `dotnet test FwLiteOnly.slnf`, and test with real FwData projects

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwHeadless/AGENTS.md:0-0
Timestamp: 2025-12-05T10:28:05.034Z
Learning: The most dangerous sync logic is located in `backend/FwLite/FwLiteProjectSync/` with the core algorithm in `CrdtFwdataProjectSyncService.cs`; refer to `backend/FwLite/AGENTS.md` for detailed sync documentation

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/LcmCrdt/CrdtMiniLcmApi.cs : When adding new fields to MiniLcm models, implement read/write logic in CrdtMiniLcmApi and evaluate whether a new API method is needed

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/MiniLcm/Models/*.cs : When adding a new field/property to MiniLcm model classes (Entry, Sense, etc.), add the property to the `Copy()` method and add to `GetReferences()` and `RemoveReference()` methods if applicable
✅ Actions performed

Full review triggered.

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: 3

♻️ Duplicate comments (1)
backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs (1)

395-399: ⚠️ Potential issue | 🟠 Major

Block every patch op that targets MorphType, not just replace.

Line 397 only catches Replace on /MorphType. add, remove, copy, or move to that same path still bypass this guard and get forwarded into JsonPatchChange<MorphTypeData>, so the immutability rule is still escapable.

Suggested guard
-        if (operations.Any(
-            op => op.Path == $"/{nameof(MorphTypeData.MorphType)}" &&
-            op.OperationType == SystemTextJsonPatch.Operations.OperationType.Replace))
+        if (operations.Any(op =>
+            string.Equals(op.Path, $"/{nameof(MorphTypeData.MorphType)}", StringComparison.OrdinalIgnoreCase)))
         {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs` around lines 395 - 399, The current
guard in the update handling (using update.Patch.GetOperations()) only blocks
OperationType.Replace against the /MorphType path, allowing other operation
types (add/remove/copy/move) to mutate MorphType; change the predicate to block
any operation whose Path equals $"/{nameof(MorphTypeData.MorphType)}" regardless
of op.OperationType, so no JsonPatch operation can target
MorphTypeData.MorphType (this affects the code that builds/forwards
JsonPatchChange<MorphTypeData>).
🧹 Nitpick comments (4)
backend/FwLite/MiniLcm/SyncHelpers/MorphTypeDataSync.cs (1)

22-28: Don't silently swallow immutable-kind drift.

Returning 0 preserves the invariant, but sync callers only treat that as “no counted change.” If this branch ever fires, the two stores can stay divergent with no signal. Please emit a warning/metric here so unexpected MorphType mutations are diagnosable.

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

In `@backend/FwLite/MiniLcm/SyncHelpers/MorphTypeDataSync.cs` around lines 22 -
28, The branch in MorphTypeDataSync that returns 0 when before.MorphType !=
after.MorphType silently swallows an immutable-kind drift; update the branch
inside the method where the code checks if (before.MorphType != after.MorphType)
to emit a diagnostic (e.g., process/logger warning and/or increment a metric
counter) before returning 0 so callers and observability systems see that a
rejected MorphType change occurred; reference the before.MorphType !=
after.MorphType check and the return 0 in MorphTypeDataSync to add the
warning/metric increment and include contextual identifiers (object Id,
before/after values) in the message.
backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs (1)

73-76: Log the canonical morph-type ID returned by the write API.

CreateMorphTypeData() de-dupes by MorphType, so it can hand back an existing row with a different Id than morphTypeData.Id. Logging the source Guid here makes retries/races look like a different object was inserted.

♻️ Suggested tweak
-            await importTo.CreateMorphTypeData(morphTypeData);
-            logger.LogInformation("Imported morph type {Id} ({typ})", morphTypeData.Id, morphTypeData.MorphType);
+            var importedMorphTypeData = await importTo.CreateMorphTypeData(morphTypeData);
+            logger.LogInformation(
+                "Imported morph type {Id} ({MorphType})",
+                importedMorphTypeData.Id,
+                importedMorphTypeData.MorphType);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs` around lines 73 - 76, The
log is using the source morphTypeData.Id but CreateMorphTypeData(MorphTypeData)
may return a de-duped/existing record with a different canonical Id; update the
await foreach block to capture the return value from
importTo.CreateMorphTypeData(morphTypeData) and log the returned object's Id
(the canonical morph-type Id) along with MorphType to avoid confusing
retries/races; reference the await foreach over
importFrom.GetAllMorphTypeData(), the call to importTo.CreateMorphTypeData, and
the logger.LogInformation call so you replace the logged morphTypeData.Id with
the Id from the method's return value.
backend/FwLite/LcmCrdt/Changes/CreateMorphTypeDataChange.cs (1)

21-32: Document the tombstone-on-duplicate behavior.

This check intentionally treats tombstoned rows as duplicates, which looks wrong on first read. A short comment here would make it much harder for someone to “fix” this to FilterDeleted() later and accidentally break the first-one-wins contract.

📝 Suggested clarification
-        var alreadyExists = await context.GetObjectsOfType<MorphTypeData>().AnyAsync(m => m.MorphType == MorphType);
+        // Deleted rows still count here: MorphType is globally unique, and duplicate inserts
+        // are resolved by the SQLite UNIQUE ... ON CONFLICT IGNORE constraint.
+        var alreadyExists = await context.GetObjectsOfType<MorphTypeData>().AnyAsync(m => m.MorphType == MorphType);

Based on learnings: NewEntity() cannot return null, and MorphType duplicates are intentionally resolved “first one wins” via SQLite UNIQUE ON CONFLICT IGNORE.

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

In `@backend/FwLite/LcmCrdt/Changes/CreateMorphTypeDataChange.cs` around lines 21
- 32, Add a concise comment above the duplicate-check explaining that the
AnyAsync check on MorphTypeData intentionally does not call FilterDeleted() so
that tombstoned rows count as duplicates (i.e., tombstone-on-duplicate),
preserving the "first-one-wins" semantics enforced by SQLite UNIQUE ON CONFLICT
IGNORE; reference the MorphTypeData type and the alreadyExists variable/logic in
CreateMorphTypeDataChange and note that NewEntity() cannot return null so this
behavior is required and must not be changed to FilterDeleted().
backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.cs (1)

14-38: Explain why MorphType is declared unique twice.

The raw DDL already gives MorphType an inline UNIQUE ON CONFLICT IGNORE, so the later named unique index reads like redundant schema. A short note that the index is kept only to align EF Core's model snapshot with HasIndex(m => m.MorphType).IsUnique() would make this much safer to maintain.

📝 Suggested comment
             migrationBuilder.CreateIndex(
+                // Keep this named index in addition to the inline UNIQUE ON CONFLICT IGNORE
+                // constraint above so EF Core's model snapshot stays aligned with
+                // HasIndex(m => m.MorphType).IsUnique().
                 name: "IX_MorphTypeData_MorphType",
                 table: "MorphTypeData",
                 column: "MorphType",
                 unique: true);

Based on learnings: "A separate migrationBuilder.CreateIndex() for IX_MorphTypeData_MorphType is also emitted so EF Core's model snapshot stays consistent with HasIndex(...).IsUnique() configuration."

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

In `@backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.cs`
around lines 14 - 38, Add a brief explanatory comment in the migration near the
raw SQL CREATE TABLE for MorphTypeData and the subsequent
migrationBuilder.CreateIndex call: explain that MorphType is declared UNIQUE
inline in the DDL for ON CONFLICT IGNORE behavior, and the separate CreateIndex
(IX_MorphTypeData_MorphType) is emitted solely so EF Core's model snapshot stays
consistent with HasIndex(m => m.MorphType).IsUnique() — reference the CREATE
TABLE block and the migrationBuilder.CreateIndex("IX_MorphTypeData_MorphType",
...) to make the intent clear for future maintainers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs`:
- Around line 41-52: expectedMorphTypes is lazily generated and calls
Guid.NewGuid() each enumeration, so retries see different data; materialize the
sequence once (e.g., call ToList() when creating expectedMorphTypes) and use
that single stable collection for all assertions, and change the equivalence
checks to assert the paired values (e.g., compare objects or the tuples {
MorphType, Name }) rather than relying on re-enumeration; update the creation of
expectedMorphTypes (the Enumerable.Range(...).Select(...) that builds
MorphTypeData) to produce a concrete List<MorphTypeData> and adjust the
BeEquivalentTo assertions to compare either the full MorphTypeData instances or
the (MorphType, Name) projection so the fixture is stable across retries.

In `@backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs`:
- Around line 125-127: The sync currently uses SimpleStringDiff for LeadingToken
and TrailingToken in MorphTypeDataSync.Sync which lets a null CRDT value produce
JSON Patch remove ops back to FWData; modify the token diffing so it is
lossless: update MorphTypeDataSync.Sync (or the SimpleStringDiff logic it calls)
to treat a null/absent token on the source side as “no change” (do not emit
remove) unless the source explicitly indicates an intentional deletion, and only
emit add/replace/remove when the source contains a non-null explicit operation;
specifically ensure LeadingToken and TrailingToken comparisons skip generating
remove patches when crdt value is null during the CRDT→FWData pass.

In
`@backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.Designer.cs`:
- Around line 259-300: The Designer.cs snapshot for the entity
"MiniLcm.Models.MorphTypeData" is missing the unique index for the MorphType
column; update the model configuration for MorphTypeData (the lambda where
modelBuilder.Entity("MiniLcm.Models.MorphTypeData", b => { ... })) to include
b.HasIndex("MorphType").IsUnique(); — either regenerate the migration artifacts
so the snapshot is rebuilt correctly or add that HasIndex call manually in the
MorphTypeData configuration to match the migration's CreateIndex for
IX_MorphTypeData_MorphType.

---

Duplicate comments:
In `@backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs`:
- Around line 395-399: The current guard in the update handling (using
update.Patch.GetOperations()) only blocks OperationType.Replace against the
/MorphType path, allowing other operation types (add/remove/copy/move) to mutate
MorphType; change the predicate to block any operation whose Path equals
$"/{nameof(MorphTypeData.MorphType)}" regardless of op.OperationType, so no
JsonPatch operation can target MorphTypeData.MorphType (this affects the code
that builds/forwards JsonPatchChange<MorphTypeData>).

---

Nitpick comments:
In `@backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs`:
- Around line 73-76: The log is using the source morphTypeData.Id but
CreateMorphTypeData(MorphTypeData) may return a de-duped/existing record with a
different canonical Id; update the await foreach block to capture the return
value from importTo.CreateMorphTypeData(morphTypeData) and log the returned
object's Id (the canonical morph-type Id) along with MorphType to avoid
confusing retries/races; reference the await foreach over
importFrom.GetAllMorphTypeData(), the call to importTo.CreateMorphTypeData, and
the logger.LogInformation call so you replace the logged morphTypeData.Id with
the Id from the method's return value.

In `@backend/FwLite/LcmCrdt/Changes/CreateMorphTypeDataChange.cs`:
- Around line 21-32: Add a concise comment above the duplicate-check explaining
that the AnyAsync check on MorphTypeData intentionally does not call
FilterDeleted() so that tombstoned rows count as duplicates (i.e.,
tombstone-on-duplicate), preserving the "first-one-wins" semantics enforced by
SQLite UNIQUE ON CONFLICT IGNORE; reference the MorphTypeData type and the
alreadyExists variable/logic in CreateMorphTypeDataChange and note that
NewEntity() cannot return null so this behavior is required and must not be
changed to FilterDeleted().

In `@backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.cs`:
- Around line 14-38: Add a brief explanatory comment in the migration near the
raw SQL CREATE TABLE for MorphTypeData and the subsequent
migrationBuilder.CreateIndex call: explain that MorphType is declared UNIQUE
inline in the DDL for ON CONFLICT IGNORE behavior, and the separate CreateIndex
(IX_MorphTypeData_MorphType) is emitted solely so EF Core's model snapshot stays
consistent with HasIndex(m => m.MorphType).IsUnique() — reference the CREATE
TABLE block and the migrationBuilder.CreateIndex("IX_MorphTypeData_MorphType",
...) to make the intent clear for future maintainers.

In `@backend/FwLite/MiniLcm/SyncHelpers/MorphTypeDataSync.cs`:
- Around line 22-28: The branch in MorphTypeDataSync that returns 0 when
before.MorphType != after.MorphType silently swallows an immutable-kind drift;
update the branch inside the method where the code checks if (before.MorphType
!= after.MorphType) to emit a diagnostic (e.g., process/logger warning and/or
increment a metric counter) before returning 0 so callers and observability
systems see that a rejected MorphType change occurred; reference the
before.MorphType != after.MorphType check and the return 0 in MorphTypeDataSync
to add the warning/metric increment and include contextual identifiers (object
Id, before/after values) in the message.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 02d9a0ab-7542-4984-907b-5fa52b34d400

📥 Commits

Reviewing files that changed from the base of the PR and between 6a341ca and cbac9a3.

📒 Files selected for processing (33)
  • backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateMorphTypeDataProxy.cs
  • backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs
  • backend/FwLite/FwLiteProjectSync.Tests/ProjectSnapshotSerializationTests.cs
  • backend/FwLite/FwLiteProjectSync.Tests/Snapshots/sena-3_snapshot.2025-09-30.verified.txt
  • backend/FwLite/FwLiteProjectSync.Tests/Snapshots/sena-3_snapshot.2025-09-31.verified.txt
  • backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs
  • backend/FwLite/FwLiteProjectSync.Tests/sena-3-live.verified.sqlite
  • backend/FwLite/FwLiteProjectSync.Tests/sena-3-live_snapshot.verified.txt
  • backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs
  • backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs
  • backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt
  • backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs
  • backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs
  • backend/FwLite/LcmCrdt.Tests/Data/MigrationTests_FromScriptedDb.v1.ProjectSnapshot.verified.txt
  • backend/FwLite/LcmCrdt.Tests/Data/MigrationTests_FromScriptedDb.v2.ProjectSnapshot.verified.txt
  • backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt
  • backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt
  • backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt
  • backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyIObjectWithIdModels.verified.txt
  • backend/FwLite/LcmCrdt.Tests/SnapshotAtCommitServiceTests.cs
  • backend/FwLite/LcmCrdt/Changes/CreateMorphTypeDataChange.cs
  • backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs
  • backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs
  • backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs
  • backend/FwLite/LcmCrdt/LcmCrdtKernel.cs
  • backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.Designer.cs
  • backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.cs
  • backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs
  • backend/FwLite/MiniLcm/MiniLcmApiExtensions.cs
  • backend/FwLite/MiniLcm/Models/IObjectWithId.cs
  • backend/FwLite/MiniLcm/Models/MorphType.cs
  • backend/FwLite/MiniLcm/ProjectSnapshot.cs
  • backend/FwLite/MiniLcm/SyncHelpers/MorphTypeDataSync.cs

@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 11, 2026

Actionable comments posted: 3
♻️ Duplicate comments (1)

backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs (1)> 395-399: ⚠️ Potential issue | 🟠 Major

Block every patch op that targets MorphType, not just replace.
Line 397 only catches Replace on /MorphType. add, remove, copy, or move to that same path still bypass this guard and get forwarded into JsonPatchChange<MorphTypeData>, so the immutability rule is still escapable.

Done in commit 264f6d6.

🧹 Nitpick comments (4)

backend/FwLite/MiniLcm/SyncHelpers/MorphTypeDataSync.cs (1)> 22-28: Don't silently swallow immutable-kind drift.

Returning 0 preserves the invariant, but sync callers only treat that as “no counted change.” If this branch ever fires, the two stores can stay divergent with no signal. Please emit a warning/metric here so unexpected MorphType mutations are diagnosable.

Commit e6a5bb1 fixes this by throwing an InvalidOperationException instead of silently swallowing an invalid update, allowing this to become a runtime error that will be reported to the user.

backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs (1)> 73-76: Log the canonical morph-type ID returned by the write API.

CreateMorphTypeData() de-dupes by MorphType, so it can hand back an existing row with a different Id than morphTypeData.Id. Logging the source Guid here makes retries/races look like a different object was inserted.

Not worth doing, as we also log the MorphType enum value which will make it clear what happened if a different GUID shows up the logs. This will not confuse any of our developers.

backend/FwLite/LcmCrdt/Changes/CreateMorphTypeDataChange.cs (1)> 21-32: Document the tombstone-on-duplicate behavior.

This check intentionally treats tombstoned rows as duplicates, which looks wrong on first read. A short comment here would make it much harder for someone to “fix” this to FilterDeleted() later and accidentally break the first-one-wins contract.

Comment added in commit e6a5bb1.

backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.cs (1)> 14-38: Explain why MorphType is declared unique twice.

The raw DDL already gives MorphType an inline UNIQUE ON CONFLICT IGNORE, so the later named unique index reads like redundant schema. A short note that the index is kept only to align EF Core's model snapshot with HasIndex(m => m.MorphType).IsUnique() would make this much safer to maintain.

Comment added in commit e6a5bb1.

@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 11, 2026

@CodeRabbit fullreview

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 11, 2026

@rmunn: Triggering a full review of the PR now, incorporating all the recent commits (264f6d6, e6a5bb1) that addressed the previous round of comments.


🧠 Learnings used
Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 0
File: :0-0
Timestamp: 2026-03-10T08:33:24.170Z
Learning: In backend/FwLite/LcmCrdt, the MorphTypeData table has a unique index on the MorphType column (corresponding to the MorphType C# enum). The intended conflict resolution is "first one wins" using SQLite's ON CONFLICT IGNORE, because: (1) CRDT's NewEntity() cannot return null, so duplicate CreateMorphTypeDataChange objects (from concurrent offline users) result in a second row with DeletedAt set, potentially violating the unique constraint; (2) In practice, users cannot create new morph types in FieldWorks Classic or Lite. SQLite's ON CONFLICT IGNORE clause can only be applied to constraints defined inline in CREATE TABLE (not to separately-created indexes via CREATE UNIQUE INDEX), so implementing this requires either rebuilding the table with an inline UNIQUE ... ON CONFLICT IGNORE constraint or using an EF Core SaveChanges interceptor with INSERT OR IGNORE.

Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 0
File: :0-0
Timestamp: 2026-03-10T08:50:35.872Z
Learning: In sillsdev/languageforge-lexbox LcmCrdt, when a SQLite table needs `UNIQUE ON CONFLICT IGNORE` on a column (e.g., MorphTypeData.MorphType), the EF Core migration must use `migrationBuilder.Sql()` for the CREATE TABLE DDL (since EF Core's CreateTable() API doesn't support ON CONFLICT clauses). A separate named `CREATE UNIQUE INDEX` should also be emitted via `migrationBuilder.Sql()` so EF Core's model snapshot stays consistent with `HasIndex(...).IsUnique()` configuration. SQLite allows both the inline constraint and the named index on the same column; the inline ON CONFLICT IGNORE fires first on duplicate inserts.

Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 2192
File: backend/FwLite/LcmCrdt/Changes/CreateMorphTypeDataChange.cs:21-32
Timestamp: 2026-03-11T02:23:21.607Z
Learning: In sillsdev/languageforge-lexbox LcmCrdt, the MorphTypeData table's UNIQUE ON CONFLICT IGNORE constraint on the MorphType column is implemented in migration 20260306045826_AddMorphTypeDataTable.cs using migrationBuilder.Sql() with a hand-written CREATE TABLE DDL (since EF Core's CreateTable() API doesn't support ON CONFLICT clauses). A separate migrationBuilder.CreateIndex() for IX_MorphTypeData_MorphType is also emitted so EF Core's model snapshot stays consistent with HasIndex(...).IsUnique() configuration. A separate migration 20260310065457_UniqueIndexOnMorphType (which used CREATE UNIQUE INDEX without ON CONFLICT support) was deleted as part of this consolidation.

Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 0
File: :0-0
Timestamp: 2026-03-10T08:34:03.262Z
Learning: In the LcmCrdt SQLite database for sillsdev/languageforge-lexbox, `ON CONFLICT IGNORE` for a unique constraint on MorphTypeData.MorphType cannot be implemented via a standalone `CREATE UNIQUE INDEX` statement (SQLite doesn't support ON CONFLICT clauses on CREATE INDEX). It must be a table-level constraint in the CREATE TABLE DDL, requiring a table-rebuild migration. The practical risk is low because morph type creation is restricted in both FieldWorks Classic and FW Lite, but the theoretical CRDT offline-sync race condition exists.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/MiniLcm/SyncHelpers/*.cs : When adding new fields to MiniLcm models, add diff logic for the new field in the appropriate sync helper file (e.g., EntrySync.cs for Entry fields). Sync bugs here cause data loss.

Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 2192
File: backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs:125-127
Timestamp: 2026-03-11T04:58:14.077Z
Learning: In sillsdev/languageforge-lexbox backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs SyncInternal, the two-pass sync pattern is intentional and lossless: pass 1 (e.g., line 126 for MorphTypeData) updates the live CRDT from FW data using the project snapshot as "before"; pass 2 (e.g., line 127) reads the *already-updated* live CRDT and pushes it to FW. This means a null value in the CRDT snapshot cannot destructively wipe an FW value — it will be reconciled to the FW value in pass 1 before pass 2 ever runs. A null reaching FW in pass 2 only happens when the user intentionally deleted it in CRDT, which is correct bidirectional sync behavior.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/MiniLcm/Models/Entry.cs : Entry is a core model that affects all downstream systems. When adding fields to Entry, ensure proper implementation in Copy(), GetReferences(), RemoveReference(), CRDT mirroring, change handling, sync logic, FwData mapping, and comprehensive test coverage across all three test projects

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/FwLiteProjectSync.Tests/**/*.cs : When fixing a sync bug, reproduce it with a test. Use `DryRunMiniLcmApi` to see what changes would be made. Debug through `CrdtFwdataProjectSyncService.Sync()` and verify `ProjectSnapshot` handling.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/*.cs : Before making changes to MiniLcm, LcmCrdt, FwDataMiniLcmBridge, or FwLiteProjectSync: read the relevant section thoroughly, understand the sync flow end-to-end, run the full test suite with `dotnet test FwLiteOnly.slnf`, and test with real FwData projects

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/LcmCrdt/Objects/*.cs : When adding a new field/property to MiniLcm models, mirror the property exactly in the corresponding LcmCrdt entity class

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Before making changes to MiniLcm, LcmCrdt, FwDataMiniLcmBridge, or FwLiteProjectSync: read the relevant section thoroughly, understand the sync flow end-to-end, run the full test suite with `dotnet test FwLiteOnly.slnf`, and test with real FwData projects
✅ Actions performed

Full review triggered.

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 (1)
backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs (1)

41-52: ⚠️ Potential issue | 🟡 Minor

Exercise all non-fallback MorphType values in this resumability test.

This fixture only imports values 1..10, but backend/FwLite/MiniLcm/Models/MorphType.cs defines additional real morph types beyond that range. A resumability bug tied to one of the later enum members would still pass here. Please build expectedMorphTypes from the full non-fallback enum set instead of a hard-coded range.

Based on learnings, "When fixing a sync bug, reproduce it with a test."

Also applies to: 95-96, 128-134

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

In `@backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs` around lines
41 - 52, The test currently builds expectedMorphTypes using
Enumerable.Range(1,10) which misses later real MorphType enum members; update
the construction to iterate all non-fallback members of the MorphType enum
(e.g., use Enum.GetValues(typeof(MorphType)) and filter out the fallback/default
value) and create a MorphTypeData for each such enum member (keeping the same
fields: Id, Name, Abbreviation, Description, LeadingToken, TrailingToken,
MorphType, SecondaryOrder). Do the same replacement for the other occurrences
mentioned (around the blocks that start at the locations corresponding to lines
95-96 and 128-134) so the test exercises every real MorphType rather than a
hard-coded 1..10 range.
🧹 Nitpick comments (3)
backend/FwLite/LcmCrdt.Tests/SnapshotAtCommitServiceTests.cs (1)

27-27: Add a MorphTypeData rollback case alongside this.

This fixes order sensitivity, but none of the tests in this file actually create or mutate AllMorphTypeData, so snapshot-at-commit regressions for morph types could still slip through.

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

In `@backend/FwLite/LcmCrdt.Tests/SnapshotAtCommitServiceTests.cs` at line 27, The
tests currently disable strict ordering for AllMorphTypeData but never exercise
it, so add a MorphTypeData rollback case to the test setup in
SnapshotAtCommitServiceTests: where the mocked rollback sequence (the code that
builds rollback events used by the snapshot-at-commit tests) constructs other
rollback items, append a rollback event for a morph type (e.g.,
create/update/delete of a MorphTypeData entry) targeting AllMorphTypeData so the
tests actually create or mutate that collection; ensure the new case is added
alongside the existing WithoutStrictOrderingFor(x => x.AllMorphTypeData)
configuration and uses the same rollback event shape used elsewhere in the file
so it participates in the snapshot/commit assertions.
backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs (1)

198-203: Add a MorphTypeData update case here too.

This only exercises creation. The new editable-field update path—and the MorphType immutability rule—still are not covered by the shared “all changes” test generator, so regressions there can pass unnoticed.

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

In `@backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs` around lines 198 -
203, Add a MorphTypeData update case after the CreateMorphTypeDataChange:
construct new editable values (e.g., new MultiString for Name and Abbreviation,
new RichMultiString for Description), create an UpdateMorphTypeDataChange using
the same morphTypeData.Id and the new editable-field values, and yield it via
new ChangeWithDependencies(UpdateMorphTypeDataChange). Also add a second case
that attempts to change the immutable MorphType property (e.g., an
UpdateMorphTypeDataChange that sets a different MorphType) so the MorphType
immutability rule is exercised by the shared “all changes” test generator.
backend/FwLite/MiniLcm/SyncHelpers/MorphTypeDataSync.cs (1)

55-55: Consider adding MorphType validation to FwDataMiniLcmApi.UpdateMorphTypeData() for defense-in-depth.

While the sync layer (here) and CRDT layer both guard against MorphType changes, FwDataMiniLcmApi.UpdateMorphTypeData() at backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs:581-594 does not validate the patch. Although the UpdateMorphTypeDataProxy doesn't persist MorphType changes (since it doesn't override the property to map to LCM), adding validation there would provide consistency across all API implementations.

♻️ Optional: Add validation in FwDataMiniLcmApi.UpdateMorphTypeData

In backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs, add validation similar to CrdtMiniLcmApi:

public Task<MorphTypeData> UpdateMorphTypeData(Guid id, UpdateObjectInput<MorphTypeData> update)
{
    // Add this validation for consistency with CrdtMiniLcmApi
    if (update.Patch.Operations.Any(op => op.Path == $"/{nameof(MorphTypeData.MorphType)}"))
    {
        throw new InvalidOperationException("MorphTypes cannot be changed to a different kind after creation");
    }
    
    var lcmMorphType = MorphTypeRepository.GetObject(id);
    // ... rest of implementation
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/FwLite/MiniLcm/SyncHelpers/MorphTypeDataSync.cs` at line 55, Add
defensive validation in FwDataMiniLcmApi.UpdateMorphTypeData to reject any JSON
Patch operation that targets MorphTypeData.MorphType: inspect the incoming
UpdateObjectInput<MorphTypeData>.Patch.Operations and if any op.Path equals
$"/{nameof(MorphTypeData.MorphType)}" throw an InvalidOperationException with a
clear message (e.g., "MorphTypes cannot be changed to a different kind after
creation"); keep the rest of the method flow (retrieving lcmMorphType via
MorphTypeRepository.GetObject and existing update logic) unchanged for
consistency with CrdtMiniLcmApi.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs`:
- Around line 41-52: The test currently builds expectedMorphTypes using
Enumerable.Range(1,10) which misses later real MorphType enum members; update
the construction to iterate all non-fallback members of the MorphType enum
(e.g., use Enum.GetValues(typeof(MorphType)) and filter out the fallback/default
value) and create a MorphTypeData for each such enum member (keeping the same
fields: Id, Name, Abbreviation, Description, LeadingToken, TrailingToken,
MorphType, SecondaryOrder). Do the same replacement for the other occurrences
mentioned (around the blocks that start at the locations corresponding to lines
95-96 and 128-134) so the test exercises every real MorphType rather than a
hard-coded 1..10 range.

---

Nitpick comments:
In `@backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs`:
- Around line 198-203: Add a MorphTypeData update case after the
CreateMorphTypeDataChange: construct new editable values (e.g., new MultiString
for Name and Abbreviation, new RichMultiString for Description), create an
UpdateMorphTypeDataChange using the same morphTypeData.Id and the new
editable-field values, and yield it via new
ChangeWithDependencies(UpdateMorphTypeDataChange). Also add a second case that
attempts to change the immutable MorphType property (e.g., an
UpdateMorphTypeDataChange that sets a different MorphType) so the MorphType
immutability rule is exercised by the shared “all changes” test generator.

In `@backend/FwLite/LcmCrdt.Tests/SnapshotAtCommitServiceTests.cs`:
- Line 27: The tests currently disable strict ordering for AllMorphTypeData but
never exercise it, so add a MorphTypeData rollback case to the test setup in
SnapshotAtCommitServiceTests: where the mocked rollback sequence (the code that
builds rollback events used by the snapshot-at-commit tests) constructs other
rollback items, append a rollback event for a morph type (e.g.,
create/update/delete of a MorphTypeData entry) targeting AllMorphTypeData so the
tests actually create or mutate that collection; ensure the new case is added
alongside the existing WithoutStrictOrderingFor(x => x.AllMorphTypeData)
configuration and uses the same rollback event shape used elsewhere in the file
so it participates in the snapshot/commit assertions.

In `@backend/FwLite/MiniLcm/SyncHelpers/MorphTypeDataSync.cs`:
- Line 55: Add defensive validation in FwDataMiniLcmApi.UpdateMorphTypeData to
reject any JSON Patch operation that targets MorphTypeData.MorphType: inspect
the incoming UpdateObjectInput<MorphTypeData>.Patch.Operations and if any
op.Path equals $"/{nameof(MorphTypeData.MorphType)}" throw an
InvalidOperationException with a clear message (e.g., "MorphTypes cannot be
changed to a different kind after creation"); keep the rest of the method flow
(retrieving lcmMorphType via MorphTypeRepository.GetObject and existing update
logic) unchanged for consistency with CrdtMiniLcmApi.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 918315d6-990c-4d34-ac13-e2e77bd6c59c

📥 Commits

Reviewing files that changed from the base of the PR and between 6a341ca and e6a5bb1.

📒 Files selected for processing (33)
  • backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateMorphTypeDataProxy.cs
  • backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs
  • backend/FwLite/FwLiteProjectSync.Tests/ProjectSnapshotSerializationTests.cs
  • backend/FwLite/FwLiteProjectSync.Tests/Snapshots/sena-3_snapshot.2025-09-30.verified.txt
  • backend/FwLite/FwLiteProjectSync.Tests/Snapshots/sena-3_snapshot.2025-09-31.verified.txt
  • backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs
  • backend/FwLite/FwLiteProjectSync.Tests/sena-3-live.verified.sqlite
  • backend/FwLite/FwLiteProjectSync.Tests/sena-3-live_snapshot.verified.txt
  • backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs
  • backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs
  • backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt
  • backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs
  • backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs
  • backend/FwLite/LcmCrdt.Tests/Data/MigrationTests_FromScriptedDb.v1.ProjectSnapshot.verified.txt
  • backend/FwLite/LcmCrdt.Tests/Data/MigrationTests_FromScriptedDb.v2.ProjectSnapshot.verified.txt
  • backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt
  • backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt
  • backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt
  • backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyIObjectWithIdModels.verified.txt
  • backend/FwLite/LcmCrdt.Tests/SnapshotAtCommitServiceTests.cs
  • backend/FwLite/LcmCrdt/Changes/CreateMorphTypeDataChange.cs
  • backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs
  • backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs
  • backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs
  • backend/FwLite/LcmCrdt/LcmCrdtKernel.cs
  • backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.Designer.cs
  • backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.cs
  • backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs
  • backend/FwLite/MiniLcm/MiniLcmApiExtensions.cs
  • backend/FwLite/MiniLcm/Models/IObjectWithId.cs
  • backend/FwLite/MiniLcm/Models/MorphType.cs
  • backend/FwLite/MiniLcm/ProjectSnapshot.cs
  • backend/FwLite/MiniLcm/SyncHelpers/MorphTypeDataSync.cs

@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 11, 2026

@myieye - Done with CodeRabbit, ready for human review now.

@rmunn rmunn requested a review from myieye March 11, 2026 06:48
Copy link
Copy Markdown
Collaborator

@myieye myieye left a comment

Choose a reason for hiding this comment

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

Looking good! I found some things that I think are worth changing.

FYI, you can refer to this emoji-legend if you're curious what my emojis mean 😉.

@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 12, 2026

@CodeRabbit fullreview

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 12, 2026

@rmunn: Triggering a full review of the PR now.


🧠 Learnings used
Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 0
File: :0-0
Timestamp: 2026-03-10T08:33:24.170Z
Learning: In backend/FwLite/LcmCrdt, the MorphTypeData table has a unique index on the MorphType column (corresponding to the MorphType C# enum). The intended conflict resolution is "first one wins" using SQLite's ON CONFLICT IGNORE, because: (1) CRDT's NewEntity() cannot return null, so duplicate CreateMorphTypeDataChange objects (from concurrent offline users) result in a second row with DeletedAt set, potentially violating the unique constraint; (2) In practice, users cannot create new morph types in FieldWorks Classic or Lite. SQLite's ON CONFLICT IGNORE clause can only be applied to constraints defined inline in CREATE TABLE (not to separately-created indexes via CREATE UNIQUE INDEX), so implementing this requires either rebuilding the table with an inline UNIQUE ... ON CONFLICT IGNORE constraint or using an EF Core SaveChanges interceptor with INSERT OR IGNORE.

Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 0
File: :0-0
Timestamp: 2026-03-10T08:50:35.872Z
Learning: In sillsdev/languageforge-lexbox LcmCrdt, when a SQLite table needs `UNIQUE ON CONFLICT IGNORE` on a column (e.g., MorphTypeData.MorphType), the EF Core migration must use `migrationBuilder.Sql()` for the CREATE TABLE DDL (since EF Core's CreateTable() API doesn't support ON CONFLICT clauses). A separate named `CREATE UNIQUE INDEX` should also be emitted via `migrationBuilder.Sql()` so EF Core's model snapshot stays consistent with `HasIndex(...).IsUnique()` configuration. SQLite allows both the inline constraint and the named index on the same column; the inline ON CONFLICT IGNORE fires first on duplicate inserts.

Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 2192
File: backend/FwLite/LcmCrdt/Changes/CreateMorphTypeDataChange.cs:21-32
Timestamp: 2026-03-11T02:23:21.607Z
Learning: In sillsdev/languageforge-lexbox LcmCrdt, the MorphTypeData table's UNIQUE ON CONFLICT IGNORE constraint on the MorphType column is implemented in migration 20260306045826_AddMorphTypeDataTable.cs using migrationBuilder.Sql() with a hand-written CREATE TABLE DDL (since EF Core's CreateTable() API doesn't support ON CONFLICT clauses). A separate migrationBuilder.CreateIndex() for IX_MorphTypeData_MorphType is also emitted so EF Core's model snapshot stays consistent with HasIndex(...).IsUnique() configuration. A separate migration 20260310065457_UniqueIndexOnMorphType (which used CREATE UNIQUE INDEX without ON CONFLICT support) was deleted as part of this consolidation.

Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 0
File: :0-0
Timestamp: 2026-03-10T08:34:03.262Z
Learning: In the LcmCrdt SQLite database for sillsdev/languageforge-lexbox, `ON CONFLICT IGNORE` for a unique constraint on MorphTypeData.MorphType cannot be implemented via a standalone `CREATE UNIQUE INDEX` statement (SQLite doesn't support ON CONFLICT clauses on CREATE INDEX). It must be a table-level constraint in the CREATE TABLE DDL, requiring a table-rebuild migration. The practical risk is low because morph type creation is restricted in both FieldWorks Classic and FW Lite, but the theoretical CRDT offline-sync race condition exists.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/MiniLcm/SyncHelpers/*.cs : When adding new fields to MiniLcm models, add diff logic for the new field in the appropriate sync helper file (e.g., EntrySync.cs for Entry fields). Sync bugs here cause data loss.

Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 2192
File: backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs:125-127
Timestamp: 2026-03-11T04:58:14.077Z
Learning: In sillsdev/languageforge-lexbox backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs SyncInternal, the two-pass sync pattern is intentional and lossless: pass 1 (e.g., line 126 for MorphTypeData) updates the live CRDT from FW data using the project snapshot as "before"; pass 2 (e.g., line 127) reads the *already-updated* live CRDT and pushes it to FW. This means a null value in the CRDT snapshot cannot destructively wipe an FW value — it will be reconciled to the FW value in pass 1 before pass 2 ever runs. A null reaching FW in pass 2 only happens when the user intentionally deleted it in CRDT, which is correct bidirectional sync behavior.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/MiniLcm/Models/Entry.cs : Entry is a core model that affects all downstream systems. When adding fields to Entry, ensure proper implementation in Copy(), GetReferences(), RemoveReference(), CRDT mirroring, change handling, sync logic, FwData mapping, and comprehensive test coverage across all three test projects

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/FwLiteProjectSync.Tests/**/*.cs : When fixing a sync bug, reproduce it with a test. Use `DryRunMiniLcmApi` to see what changes would be made. Debug through `CrdtFwdataProjectSyncService.Sync()` and verify `ProjectSnapshot` handling.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/*.cs : Before making changes to MiniLcm, LcmCrdt, FwDataMiniLcmBridge, or FwLiteProjectSync: read the relevant section thoroughly, understand the sync flow end-to-end, run the full test suite with `dotnet test FwLiteOnly.slnf`, and test with real FwData projects

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Applies to backend/FwLite/**/LcmCrdt/Objects/*.cs : When adding a new field/property to MiniLcm models, mirror the property exactly in the corresponding LcmCrdt entity class

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-02-04T14:29:56.355Z
Learning: Before making changes to MiniLcm, LcmCrdt, FwDataMiniLcmBridge, or FwLiteProjectSync: read the relevant section thoroughly, understand the sync flow end-to-end, run the full test suite with `dotnet test FwLiteOnly.slnf`, and test with real FwData projects
✅ Actions performed

Full review triggered.

@rmunn rmunn requested a review from myieye March 12, 2026 06:37
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: 1

🧹 Nitpick comments (2)
backend/FwLite/MiniLcm/MiniLcmApiExtensions.cs (1)

20-20: Sort AllMorphTypeData before snapshotting.

This new array currently inherits repository iteration order, so snapshot files can churn on order-only changes. The new WithoutStrictOrderingFor(x => x.AllMorphTypeData) in backend/FwLite/LcmCrdt.Tests/SnapshotAtCommitServiceTests.cs is already compensating for that. Sorting here by an immutable key such as MorphType keeps snapshots deterministic.

♻️ Proposed fix
-            await api.GetAllMorphTypeData().ToArrayAsync(),
+            (await api.GetAllMorphTypeData().ToArrayAsync())
+                .OrderBy(m => m.MorphType)
+                .ToArray(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/FwLite/MiniLcm/MiniLcmApiExtensions.cs` at line 20, The array
returned by awaiting api.GetAllMorphTypeData() is not ordered and causes
snapshot churn; modify the call in MiniLcmApiExtensions (the invocation of
GetAllMorphTypeData().ToArrayAsync()) to sort the sequence by the immutable key
MorphType before materializing (e.g., OrderBy(m => m.MorphType) or equivalent)
so AllMorphTypeData is deterministic when snapshotting.
backend/FwLite/MiniLcm/Models/MorphType.cs (1)

38-39: Potential sync churn between null and empty string.

The SimpleStringDiff.GetStringDiff() method (in MiniLcm/SyncHelpers/SimpleStringDiff.cs) uses direct equality comparison (before == after), which treats null and "" as distinct values. If FwData stores empty strings for Prefix/Postfix while CRDT stores null, each sync cycle may generate spurious patch operations.

Per the retrieved learnings, the two-pass sync pattern prevents data loss — any null in CRDT gets reconciled from FwData in pass 1 before pass 2 runs. However, you may want to consider normalizing empty strings to null (or vice versa) in GetStringDiff or during entity creation to avoid unnecessary operations.

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

In `@backend/FwLite/MiniLcm/Models/MorphType.cs` around lines 38 - 39, The
equality check in SimpleStringDiff.GetStringDiff treats null and "" as
different, causing churn with MorphType's LeadingToken/TrailingToken; update
GetStringDiff to normalize string values before comparison (e.g., map both null
and empty string to a canonical value) or add normalization when
creating/updating MorphType entities so LeadingToken and TrailingToken are
consistently stored (null or ""), ensuring comparisons like in GetStringDiff use
the normalized values to avoid spurious diffs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs`:
- Around line 352-365: Filter out tombstoned rows by only querying live rows
(where DeletedAt == null) in both GetAllMorphTypeData and GetMorphTypeData:
replace usages of repo.AllMorphTypeData with a filtered query (e.g.,
repo.AllMorphTypeData.Where(x => x.DeletedAt == null) or call a repository
helper that returns only live rows) so GetAllMorphTypeData yields only
non-deleted MorphTypeData and GetMorphTypeData uses the same filtered source
when calling SingleOrDefaultAsync; this ensures DeleteMorphTypeData tombstones
won’t be returned by these APIs.

---

Nitpick comments:
In `@backend/FwLite/MiniLcm/MiniLcmApiExtensions.cs`:
- Line 20: The array returned by awaiting api.GetAllMorphTypeData() is not
ordered and causes snapshot churn; modify the call in MiniLcmApiExtensions (the
invocation of GetAllMorphTypeData().ToArrayAsync()) to sort the sequence by the
immutable key MorphType before materializing (e.g., OrderBy(m => m.MorphType) or
equivalent) so AllMorphTypeData is deterministic when snapshotting.

In `@backend/FwLite/MiniLcm/Models/MorphType.cs`:
- Around line 38-39: The equality check in SimpleStringDiff.GetStringDiff treats
null and "" as different, causing churn with MorphType's
LeadingToken/TrailingToken; update GetStringDiff to normalize string values
before comparison (e.g., map both null and empty string to a canonical value) or
add normalization when creating/updating MorphType entities so LeadingToken and
TrailingToken are consistently stored (null or ""), ensuring comparisons like in
GetStringDiff use the normalized values to avoid spurious diffs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e566f485-c8f8-4d47-8bff-4847158d06e7

📥 Commits

Reviewing files that changed from the base of the PR and between 6a341ca and ad2a9fd.

📒 Files selected for processing (34)
  • backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs
  • backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateMorphTypeDataProxy.cs
  • backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs
  • backend/FwLite/FwLiteProjectSync.Tests/ProjectSnapshotSerializationTests.cs
  • backend/FwLite/FwLiteProjectSync.Tests/Snapshots/sena-3_snapshot.2026-03-11.verified.txt
  • backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs
  • backend/FwLite/FwLiteProjectSync.Tests/sena-3-live.verified.sqlite
  • backend/FwLite/FwLiteProjectSync.Tests/sena-3-live_snapshot.verified.txt
  • backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs
  • backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs
  • backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs
  • backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt
  • backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs
  • backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs
  • backend/FwLite/LcmCrdt.Tests/Data/MigrationTests_FromScriptedDb.v1.ProjectSnapshot.verified.txt
  • backend/FwLite/LcmCrdt.Tests/Data/MigrationTests_FromScriptedDb.v2.ProjectSnapshot.verified.txt
  • backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt
  • backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt
  • backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt
  • backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyIObjectWithIdModels.verified.txt
  • backend/FwLite/LcmCrdt.Tests/SnapshotAtCommitServiceTests.cs
  • backend/FwLite/LcmCrdt/Changes/CreateMorphTypeDataChange.cs
  • backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs
  • backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs
  • backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs
  • backend/FwLite/LcmCrdt/LcmCrdtKernel.cs
  • backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.Designer.cs
  • backend/FwLite/LcmCrdt/Migrations/20260306045826_AddMorphTypeDataTable.cs
  • backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs
  • backend/FwLite/MiniLcm/MiniLcmApiExtensions.cs
  • backend/FwLite/MiniLcm/Models/IObjectWithId.cs
  • backend/FwLite/MiniLcm/Models/MorphType.cs
  • backend/FwLite/MiniLcm/ProjectSnapshot.cs
  • backend/FwLite/MiniLcm/SyncHelpers/MorphTypeDataSync.cs

Copy link
Copy Markdown
Collaborator

@myieye myieye left a comment

Choose a reason for hiding this comment

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

Just two small things I noticed before I do a deeper investigation.

@github-actions github-actions bot added the 📦 Lexbox issues related to any server side code, fw-headless included label Mar 14, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 14, 2026

C# Unit Tests

165 tests  ±0   165 ✅ ±0   18s ⏱️ ±0s
 23 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 4028077. ± Comparison against base commit 73e4ecd.

♻️ This comment has been updated with latest results.

@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 14, 2026

I suspect I need to change the JSON-encoded Sena-3 data in demo-entry-data.ts, but that's going to be a bit of a pain and I don't want to do it right now. I'll do that on Monday.

@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 14, 2026

Turns out the test failures were because of a use of the MorphType enum in develop that wasn't in my branch, so I didn't see it while renaming everything.

@rmunn rmunn requested a review from myieye March 16, 2026 06:06
@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 16, 2026

@myieye - Fixed the CreateMorphTypeChange type by using required so I no longer need to mark the MultiString properties as nullable. That's the semantics I wanted anyway, so thank you for reminding me of the details of how the required keyword works.

Did you hear from Jason about renaming LeadingToken and TrailingToken? I'm about to do that rename (once I verify that the tests have turned green again), then this should be ready for final approval. I tagged you for re-review already, but if you want to hold off until I push the LeadingToken and TrailingToken rename, that's fine.

P.S. I plan to do the rename of LeadingToken and TrailingToken in a single commit, which I will get running locally and then push. That way it will be easy to revert if we decide later that we don't want to match the FwData names. But it seems likely that we'll want to match FwData's names internally, so I'll go ahead with the rename before hearing what Jason thinks¸ as the work isn't likely to be wasted.

Copy link
Copy Markdown
Collaborator

@myieye myieye left a comment

Choose a reason for hiding this comment

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

I think this is almost there. 👍
I just noted the validation changes we agreed on that you haven't done yet.

Also, I think we should not merge this. Instead I've enabled the fw-lite CI actions to run on PRs against this branch. So, we'll merge in a bunch of other morph-type stuff like:

  • tests
  • my morph-token PR
  • adding canonical morph-types to existing crdt projects (see new sub-issue)
  • adding an interceptor for updating headwords on morph-type changes (see new sub-issue)
  • etc.

I think we want at least all of those as a "bundle" or else we can't release.

@rmunn
Copy link
Copy Markdown
Contributor Author

rmunn commented Mar 18, 2026

I just noted the validation changes we agreed on that you haven't done yet.

Validation! Slaps forehead. I was thinking yesterday "I know there's one more thing but I don't remember what it is." :-)

@rmunn rmunn requested a review from myieye March 18, 2026 09:06
Copy link
Copy Markdown
Collaborator

@myieye myieye left a comment

Choose a reason for hiding this comment

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

Arg, I forgot to submit this feedback, sorry 🙁

rmunn and others added 2 commits April 9, 2026 11:53
Implement CRUD for MorphTypeData, add DB migration

Make LeadingToken and TrailingToken nullable

Register JsonPatchChange for morphTypeData

Reject MorphTypeData updates that change MorphType

Do not allow ANY JsonPatch changes to MorphType

Rename MorphType enum to MorphTypeKind

Rename MorphTypeData to MorphType

LeadingToken -> Prefix, TrailingToken -> Postfix
Matches the names from liblcm, which may help reduce confusion.
* Add filtering on token aware headwords
* Respect SecondaryOrder when sorting
* Make FTS Headword column contain morph-tokens and all vernacular WS's
@myieye myieye force-pushed the feat/sync-morph-types branch 2 times, most recently from e3ec8c7 to 87c5dad Compare April 9, 2026 12:24
* Initial work on morph types in UI

Morph types now show leading/trailing tokens in headword, but do not yet
have a dropdown for editing them in the entry UI.

* Citation forms should not be decorated

Lexeme forms should be decorated with prefix/postfix tokens according to
the morph type, but citation forms are meant as "overrides" and should
be reproduced exactly as-is, without morph type tokens. This is the rule
used by FLEx for how it displays words, so FW Lite should do the same.

As a bonus, there is now only one `headword` function in the writing
system service, instead of two functions with the same name that did two
slightly different things.

* Fix tests

---------

Co-authored-by: Tim Haasdyk <tim_haasdyk@sil.org>
@myieye myieye force-pushed the feat/sync-morph-types branch from 87c5dad to 8ed192f Compare April 9, 2026 15:24
* Seed canonical morph-types into CRDT projects

- Add CanonicalMorphTypes with all 19 morph-type definitions (GUIDs from LibLCM)
- Seed morph-types for new projects via PreDefinedData.PredefinedMorphTypes
- Seed morph-types for existing projects in MigrateDb (before FTS refresh)
- Add EF migration to clear FTS table so headwords are rebuilt with morph tokens
- Patch legacy snapshots (empty MorphTypes) in sync layer to prevent duplicates

* Stop creating morph-types in tests. They're now prepopulated

* Stop printing verify diff content. It's too much.

* Seed morph types before API testing

* Add descriptions to canonical morph types

* Sync morph-types when importing, because they already exist in CRDT

* Verify our canonical morph-types match new fwdata projects

* Fix non-FTS relevance order with morph-tokens in query
@myieye myieye force-pushed the feat/sync-morph-types branch from 8ed192f to 374b1b2 Compare April 10, 2026 11:32
@myieye myieye force-pushed the feat/sync-morph-types branch from a7868a3 to 4028077 Compare April 10, 2026 11:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💻 FW Lite issues related to the fw lite application, not miniLcm or crdt related 📦 Lexbox issues related to any server side code, fw-headless included

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support morph type in CRDTs

2 participants