Skip to content

feat(data-types): make .dt persistence a deploy setting and survivable to roll back (DOPE-542) - #999

Open
JoaoGSP wants to merge 3 commits into
developmentfrom
feature/DOPE-542-datatype-files-release
Open

feat(data-types): make .dt persistence a deploy setting and survivable to roll back (DOPE-542)#999
JoaoGSP wants to merge 3 commits into
developmentfrom
feature/DOPE-542-datatype-files-release

Conversation

@JoaoGSP

@JoaoGSP JoaoGSP commented Aug 8, 2026

Copy link
Copy Markdown
Member

Closes DOPE-542 — the final delivery PR of the DOPE-385 series. Turns on datatypes/<Name>.dt persistence for staging.

Merging this changes nothing on its own. The flag is now a deploy setting: the staging build has to set VITE_DATATYPES_DT_FILES=true (web) / DATATYPES_DT_FILES=true (editor). Please don't close the card on merge while the feature is still dark.

What's here

1. The flag resolves from a build-time injected global, not a hard-coded constant.

The original plan — true on development, reverted to false on the promotion branch — inverts the safety. Every developmentmain promotion would need someone to remember the revert commit, and nothing catches a miss: the mirror gate compares web↔editor, not development↔main, and a constant changing value is not a merge conflict. The default becomes ships to production unless someone remembers.

Injecting it keeps the source byte-identical on both branches. Same mechanism the repos already use for APP_NAME / BUILD_DATE / BUILD_ID, and the AI feature already gates on VITE_AI_ENABLED. Un-injected builds (unit tests) read as off.

2. The read is ungated; only the write is still gated.

The flag gated reading .dt files as well as writing them, which made rollback destructive — a flag-off build hydrated from the emptied data.dataTypes and showed no data types. Now a flag-off build opens a migrated project fine and writes the legacy form back on the next save, so the flag can be turned off and on again.

3. A flag-off save deletes the .dt files.

Found while walking the desktop round trip. Hydration prefers .dt files whenever they exist, and desktop leaves them on disk (writeProjectFiles only unlinks pendingDeletions; a plain edit queues nothing). So: migrate → roll back → edit a type → save → flip on again, and the stale .dt silently wins, reverting the edit. Web never had this since its save replaces the whole file set. The two platforms now agree.

Deliberate consequence: rollback is single-use per direction. Data reaches project.json before the files are removed, so nothing is ever only-on-disk in a format nothing reads.

4. project.json is written last (editor only).

writeProjectFiles sent every entry in one unordered Promise.all. A .dt write rejecting after the index had landed — already carrying dataTypes: [] — lost that type from both places, and the first save of a migrating project is exactly when that bites. Split on the project-json category rather than on .dt: the index also declares pous: [], so POUs carry the identical exposure. Three tests, each verified to fail without the fix.

Validation

Manual, on desktop, with a purpose-built legacy fixture (enum with an initial value; struct with an ARRAY [1..10] OF INT field, a documented field and a user-type field; a 2-D array type):

  • Migrate + compile parityPROGRAM_MD5 identical before and after migration, so the generated ST is byte-for-byte the same.
  • Rollback round trip — migrate → reopen flag-off (types load) → edit → save (legacy JSON carries the edit, datatypes/ empty) → flip on (edit survives).

Note for anyone repeating this: program.st is not persisted in the build output — it's dropped as a transient intermediate. Compare PROGRAM_MD5 in build/<board>/src/defines.h.

Automated: editor jest 6107 pass / 0 fail; web vitest 6106 pass with 78 pre-existing failures (DOPE-549, baselined against a clean tree); tsc, eslint and prettier clean in both; mirror gate 1016 files / 0 diffs.

Found while fixturing — pre-existing, not blocking

  • DOPE-557 — a data type named after a bundled library function block (Matrix collides with the oscat-basic FB) produces an uncompilable project with a truncated C++ error.
  • RTOP-249 — strucpp's debug-table generator emits chained [i][j] for multi-dimensional arrays while Array2D only exposes .at(i, j).

Mirror of https://github.com/Autonomy-Logic/openplc-web/pull/658

Summary by CodeRabbit

  • New Features

    • Added a configurable option to preserve data-type files during project saves.
    • Data-type files remain readable when preservation is disabled.
    • Projects now load available data-type files consistently, including legacy projects without them.
  • Bug Fixes

    • Removed stale or unparseable data-type files after saves when preservation is disabled.
    • Ensured project metadata is written only after all project content files finish successfully, including when a write fails.

…e to roll back

Resolve the flag from a build-time injected global instead of a hard-coded
constant. The original plan — true on `development`, reverted to false on the
promotion branch — inverts the safety: every promotion needs someone to
remember the revert, and nothing catches a miss, since the mirror gate
compares web against editor rather than development against main. Injecting
it keeps the source identical on both branches and makes "on in staging, off
in production" a deploy setting. Same mechanism the repos already use for
APP_NAME / BUILD_DATE.

Ungate the read. The flag gated reading `.dt` files as well as writing them,
so a flag-off build hydrated from the emptied `data.dataTypes` and showed no
data types at all — a rollback was a data-loss event. Reading unconditionally
makes a flag-off build open a migrated project and write the legacy form back
on the next save, so the flag can be turned off and on again.

Delete the `.dt` files on a flag-off save. Hydration prefers those files
whenever they exist, and desktop leaves them on disk, so a project edited
with the flag off would silently revert to the pre-rollback copy the next
time the flag was on. Web already dropped them by omission; this makes the
two platforms agree.

Write `project.json` last. Every file went out in one unordered `Promise.all`,
so a rejected `.dt` write after the index had landed — already carrying
`dataTypes: []` — lost that type from both places. The first save of a
migrating project is exactly when that bites. Split on the project-json
category rather than on `.dt`: the index also declares `pous: []`, so POUs
carry the identical exposure.

The flag ships off, and merging this changes nothing on its own — the staging
build has to set DATATYPES_DT_FILES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvGrhB1LyJz9MBwYhjoBDY
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6dfd790d-c046-4514-a7bb-c3d5cd562243

📥 Commits

Reviewing files that changed from the base of the PR and between 11418da and 6225b76.

📒 Files selected for processing (4)
  • src/backend/editor/services/project-service/__tests__/write-project-files.test.ts
  • src/backend/editor/services/project-service/index.ts
  • src/frontend/services/__tests__/save-actions.test.ts
  • src/frontend/utils/__tests__/feature-flags.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/frontend/utils/tests/feature-flags.test.ts

Walkthrough

The change adds build-time control for datatype-file persistence, keeps datatype files loadable, removes disabled files during saves, and writes content files before project.json.

Changes

Datatype persistence

Layer / File(s) Summary
Build flag and datatype loading
configs/webpack/webpack.app-info.ts, src/globals.d.ts, src/frontend/utils/..., src/middleware/adapters/editor/...
The build injects DATATYPES_DT_FILES. Undefined values disable persistence. Project opening always passes validated datatype files to the parser.
Save-time datatype cleanup
src/frontend/services/...
Disabled persistence queues parsed and unparseable .dt files for deletion. Enabled persistence retains them. Duplicate deletion paths are removed.
Ordered project file writes
src/backend/editor/services/project-service/...
Content files write in parallel before project.json. Metadata is not written when a content write fails.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant executeSaveProject
  participant isDataTypeFilesEnabled
  participant ProjectService.writeProjectFiles
  participant Filesystem
  executeSaveProject->>isDataTypeFilesEnabled: read DATATYPES_DT_FILES
  isDataTypeFilesEnabled-->>executeSaveProject: return enabled or disabled
  executeSaveProject->>ProjectService.writeProjectFiles: submit datatype files and deletions
  ProjectService.writeProjectFiles->>Filesystem: write content files in parallel
  Filesystem-->>ProjectService.writeProjectFiles: complete or reject content writes
  ProjectService.writeProjectFiles->>Filesystem: write project.json after successful content writes
Loading

Possibly related PRs

Suggested labels: feature, enhancement

Poem

A rabbit saves each .dt file bright,
Content first, then metadata right.
If one write fails, the record waits,
Flags decide the cleanup gates.
“Hop in order!” 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: deploy-time .dt persistence with rollback support.
Description check ✅ Passed The description explains the changes, references DOPE-542, documents validation, and records test results, although it does not use the provided checklist headings.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/DOPE-542-datatype-files-release

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/editor/services/project-service/index.ts`:
- Line 500: Update writeProjectFiles to use Promise.allSettled for content
writes so every write completes before returning failure; exclude project.json
from this batch, detect any rejected result, and preserve the { success: false }
outcome. Add a regression test covering one rejected content write alongside one
delayed write.

In `@src/frontend/utils/__tests__/feature-flags.test.ts`:
- Line 11: Remove the prohibited type assertions across the listed test sites:
in src/frontend/utils/__tests__/feature-flags.test.ts:11-11, replace the
globalThis assertion with Reflect.set and Reflect.deleteProperty; in
src/middleware/adapters/editor/__tests__/project-adapter.test.ts:355-355 and
:369-369, use typed mock references or jest.mocked/vi.mocked for the configured
mock; and in src/frontend/services/__tests__/save-actions.test.ts:125-125,
retain saveProject as a typed test fixture instead of casting
ProjectPort.saveProject. Allow only as const assertions.

In `@src/middleware/adapters/editor/project-adapter.ts`:
- Around line 205-210: The dataTypeFiles handling in both project-open paths
only validates the array container, allowing malformed entries into
parseProjectFiles. Add a shared Zod schema or type guard for individual IPC file
entries, filter or reject invalid payloads before parsing, and preserve the
empty-array fallback only when the field is absent; use this helper at both
visible dataTypeFiles call sites.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d84a5f6d-52ce-4919-99d1-87fb65f6019b

📥 Commits

Reviewing files that changed from the base of the PR and between e1c9ea6 and 11418da.

📒 Files selected for processing (10)
  • configs/webpack/webpack.app-info.ts
  • src/backend/editor/services/project-service/__tests__/write-project-files.test.ts
  • src/backend/editor/services/project-service/index.ts
  • src/frontend/services/__tests__/save-actions.test.ts
  • src/frontend/services/save-actions.ts
  • src/frontend/utils/__tests__/feature-flags.test.ts
  • src/frontend/utils/feature-flags.ts
  • src/globals.d.ts
  • src/middleware/adapters/editor/__tests__/project-adapter.test.ts
  • src/middleware/adapters/editor/project-adapter.ts

Comment thread src/backend/editor/services/project-service/index.ts Outdated
Comment thread src/frontend/utils/__tests__/feature-flags.test.ts Outdated
Comment thread src/middleware/adapters/editor/project-adapter.ts
JoaoGSP and others added 2 commits August 7, 2026 23:19
… failure

`Promise.all` rejects on the first failure but leaves the other writes
running, so the save returned while the disk was still being touched — a
straggler from the failed attempt could land after, and overwrite, a write
from the user's retry. `allSettled` holds until the batch drains, then
rethrows, so `project.json` is still skipped on failure.

Also drops the type assertions from the new test cases: `Reflect` for the
injected global, `vi.mocked` for the save-port spy.

Reported by review on #999.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvGrhB1LyJz9MBwYhjoBDY

@JulioSergioFS JulioSergioFS left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PR Review — openplc-editor #999

Verdict: Approve with comments — one item worth fixing before this is turned on in staging

Three genuinely good decisions here: injecting the flag instead of hard-coding it, ungating the read so a rollback is a round trip rather than a data-loss event, and ordering project.json after the content files. The write-order fix in particular is the kind of latent bug that only shows up on the first save of a migrating project, i.e. exactly the population this release targets.

The one thing I would address before the flag goes on: the rollback guarantee rests on a deletion loop that swallows failures and still reports success (finding 1), and the load path has a related sharp edge that the ungated read newly exposes (finding 2).

What I verified against the code

  • Every isDataTypeFilesEnabled() consumer is renderer-sidefrontend/services/save-actions.ts, frontend/services/st-lsp/*, frontend/components/.../data-type/index.tsx — and both renderer webpack configs spread getAppInfoDefines() (webpack.config.renderer.dev.ts:179, webpack.config.renderer.prod.ts:168). No main-process consumer that would read an un-injected undefined. Removing the import from middleware/adapters/editor/project-adapter.ts is consistent with that.
  • The main-process read was already unconditional (project-service/index.ts:293 reads datatypes/**.dt regardless of any flag), so ungating the adapter makes the two halves agree rather than opening a new path.
  • Rename and delete already queue the old .dt into pendingDeletions (store/slices/project/slice.ts:1116 and :1139), so legacyDataTypeCleanup composes with them instead of duplicating; the new Set(...) dedupe and its test pin that.
  • A filename/declared-name mismatch cannot escape the cleanup. parse-project-files.ts passes getBaseNameFromPath(relativePath) as expectedName to parseDataTypeFromText, so a .dt whose declared type name differs from its filename is routed to unparsedDataTypeFiles — which the cleanup list also covers. The "derive the path from dt.name" shortcut is safe for that reason, not by luck.
  • The category === 'project-json' split matches the generator. iterate-write-project-files.ts yields exactly one project-json entry with relativePath: 'project.json'; splitting on the category rather than on .dt also covers the pous: [] exposure, as the description says.

Strengths

  • The injected-global argument is the right one, and it is stated correctly. "A branch that has to be edited during promotion is a branch someone forgets to edit" — and the mirror gate compares web↔editor, not development↔main, so nothing would have caught the missed revert. Reusing the APP_NAME / BUILD_DATE mechanism keeps this boring.
  • The typeof guard plus module-scope capture is correct across all three environments — webpack DefinePlugin, Vite define, and un-injected Jest/Vitest — and the tests exercise all three by jest.resetModules() + re-import rather than asserting on one.
  • Ungating the read is the single most important change here. A flag gating both sides made rollback destructive in the plainest possible way: a flag-off build hydrated from an emptied data.dataTypes and showed nothing. Making the write the only gated side is the correct asymmetry.
  • The write-order bug was found by walking the round trip, not by a test failing, and the fix generalises past the symptom. allSettled-then-rethrow instead of all is also right for a non-obvious reason that the comment states: it stops a straggler from a failed save landing on top of a write from the user's retry.
  • The manual validation is the right validation. PROGRAM_MD5 identical before and after migration is a much stronger claim than "the tests pass", and the note that program.st isn't persisted (compare build/<board>/src/defines.h) will save the next person an hour.
  • The description correctly refuses to let merge be mistaken for release. Naming that the flag is still dark, and asking that the card not be closed on merge, is exactly right for a PR whose whole point is that turning it on is a separate act.

Findings

1. (Major) The rollback guarantee rests on deletions that are swallowed and still reported as success

ProjectService.writeProjectFiles processes deletions after project.json lands, catching per file:

} catch (deleteError) {
  console.error(`Error deleting file ${filePath}:`, deleteError)
}

and then returns { success: true, message: 'Your project was saved successfully' }.

So on Windows, with datatypes/Motor.dt held open by an indexer, AV scanner or another editor, a flag-off save writes the legacy project.json, fails to unlink the .dt, and tells the user the project saved. On the next open, hydration prefers the .dt (parse-project-files.ts: dataTypeFiles.length > 0 ? dataTypesFromFiles : data.dataTypes) and the stale pre-rollback copy silently outranks the edit — which is the exact bug section 3 of this PR exists to fix, reached through a different door.

Because the deletions run after project.json has already landed carrying the full legacy data, nothing is lost by failing loudly here. Suggested: collect the failed relative paths and either return success: false with them named, or surface a specific warning that names them. Silence is the one option that reintroduces the bug.

2. (Medium) A single unparseable .dt now empties legacy dataTypes on load — and the next save persists that

parse-project-files.ts decides the source of truth on dataTypeFiles.length > 0, which counts files that failed to parse:

dataTypes: dataTypeFiles.length > 0 ? dataTypesFromFiles : ((data.dataTypes as PLCDataType[]) ?? []),

Before this PR, a flag-off build passed [] into the parser, so project.json always won and this was unreachable off the flag. Now the read is unconditional, so one unreadable datatypes/Broken.dt sitting next to a legacy project.json yields dataTypes: [] in the store — and a flag-off save then writes that emptiness back (save-actions.ts:80: dataTypes: isDataTypeFilesEnabled() ? [] : project.data.dataTypes) while legacyDataTypeCleanup deletes Broken.dt. The raw file is preserved in unparsedDataTypeFiles, but the legacy types are gone from both places.

This is directly connected to the write-order fix's own motivating scenario: a .dt write that rejects part-way leaves a truncated file on disk. The fix correctly protects project.json in that moment — but on reopen, that truncated file is enough to hide every legacy type in the UI even though project.json still holds them.

Suggested: fall back to data.dataTypes when dataTypesFromFiles is empty and every .dt failed to parse — i.e. key on "we successfully read at least one type from files", not on "at least one file exists".

3. (Medium) Nothing in this repo turns the flag on, and a build with it off is indistinguishable from one with it on

The safety argument is "the flag is a deploy setting" — but no workflow, .env sample, or build doc in this PR sets DATATYPES_DT_FILES, so "the staging build has to set it" stays a manual step in whatever produces the staging desktop build, with nothing enforcing or recording it. The failure mode is quiet in both directions: a staging build with the var unset behaves exactly like production, and the only way a tester can tell which build they have is to open a migrated project and look for a datatypes/ folder.

Two small things would close it inside this PR:

  • add the variable to the staging build workflow (or, at minimum, document it next to BUILD_DATE in webpack.app-info.ts's header and in the release notes);
  • log the effective flag value once at startup, alongside APP_NAME/BUILD_DATE, so a tester or a bug report can state which build they were on.

4. (Low) Promise.allSettled keeps only the first rejection

const rejected = settled.find((result) => result.status === 'rejected')
if (rejected?.status === 'rejected') throw rejected.reason

If the disk fills mid-save, one ENOSPC propagates and the other six failures are dropped without ever being logged. Logging every rejected reason before rethrowing the first costs two lines and turns "save failed" into something diagnosable.

5. (Low) project.json is still written non-atomically

The ordering fix removes "the index describes files that were never written", which is the right first fix. It does not remove "the index is half-written" — a writeFile that fails part-way still truncates the one file everything else depends on, and now it is also the last thing written, so it is the write most likely to be interrupted by the user closing the app. Worth a line in the new comment so the ordering rationale isn't read as "project.json is now safe", and a follow-up ticket for write-temp-then-rename.

6. (Nit) One test in the new group depends on the module system rather than the injected global

it('leaves the .dt files alone when the write side is on') uses vi.spyOn(flags, 'isDataTypeFilesEnabled'), while its three siblings set globalThis.DATATYPES_DT_FILES and re-import. Spying on a module namespace object is the one thing in this group whose behaviour depends on the transpilation mode, and this is a mirrored file that has to pass under both Jest and Vitest. Making it consistent with the other three removes a runner-specific failure mode for free.

7. (Nit) The mirror gate compares the source, not the two build configs

feature-flags.ts is byte-mirrored with openplc-web and now reads a bare global that each repo's bundler has to define — but the sync check only compares the shared surface, not configs/webpack/* against web's Vite configs. A missing define in one of web's build entries reads as "off" with no error anywhere. Worth naming, in both this PR and #658, exactly which config file was changed on each side, so the reviewer of either can check the other half.

Test assessment

The three writeProjectFiles tests are the best part of this PR. Each is stated as verified-to-fail-without-the-fix, and 'waits for the slow writes before reporting a failure' covers a property that normally goes untested entirely — that a failed save doesn't hand control back while writes are still in flight. The flag tests correctly cover all three injection states rather than just the two interesting ones, and the project-adapter pair pins both halves of the ungated read (hydrate from .dt; degrade to legacy when the IPC payload carries no array).

Gaps worth closing:

  1. The deletion loop has no test at all. makeFiles() uses deletions: [], so nothing covers the path where finding 1 lives — not the happy path, not a failing unlink, not the ordering relative to project.json. Given that this PR's third section is about deletions, that is the notable hole.
  2. No test that deletions run after project.json — the ordering that makes "nothing is ever only-on-disk in a format nothing reads" true.
  3. No test for finding 2's shape: a legacy project.json plus an unparseable .dt. That combination is the one where the load path silently disagrees with the disk.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants