@voltro/plugin-atlassian 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +412 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,418 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.15.0] — 2026-07-27
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/cli** — **A rename now carries the ALIASED importers, not only the relative ones.**
|
|
47
|
+
|
|
48
|
+
An app's web build stopped compiling after `voltro update`: 84 relative imports were rewritten correctly, 163 aliased ones across 88 files were not, and `tsc` reported 249 errors on names that no longer existed. Nothing in the codemod's output hinted that a whole class of import had been skipped.
|
|
49
|
+
|
|
50
|
+
Two independent halves, and each alone leaves the imports stale:
|
|
51
|
+
|
|
52
|
+
- The codemod's ts-morph project was built with **no `baseUrl` and no `paths`**, so `@/components/link` resolved to nothing. It now gets the compiler's own shape — the raw `paths`, not the pre-resolved Vite table, because a `paths` target is relative to `baseUrl` by definition and an absolute one does not resolve. - `SourceFile.move()` rewrites relative specifiers and **nothing else**, which is correct on its own terms: ts-morph cannot know whether the alias mapping or the file is meant to change. So the codemods now rewrite the aliased ones themselves, narrowly — only specifiers that RESOLVED to the moved file, and only the trailing stem, which needs no alias table and so cannot disagree with one. The run reports how many it rewrote.
|
|
53
|
+
|
|
54
|
+
**A file an exact `paths` entry names is left alone and reported.** Renaming `link.tsx` while `"@/link": ["src/components/link.tsx"]` points at it leaves the mapping resolving to nothing — and the same path is usually repeated in a vite/vitest alias table no codemod owns. One app hit this and then saw a taxonomy violation reported on a file the codemod had itself created.
|
|
55
|
+
|
|
56
|
+
**`page/unsuffixed-in-pages` is now an error, not a warning.** The comment justifying the warning contradicted the scanner it described: that bucket is already narrowed to a default export nothing imports, which is what an unmigrated page looks like and what a co-located component never does. The failure it names is invisible everywhere else — an unmigrated route simply 404s, with a clean `tsc` and a green suite. One app finished a migration with 51 of them. `codemod: none`: the rename it asks for already ships as `0.14.0/03_pages-suffix`, and nothing about a user's SOURCE changes here — what changes is that `voltro check` now fails on a route that does not route.
|
|
57
|
+
- **@voltro/protocol, @voltro/client, @voltro/web** — **`errorTag` moves from `@voltro/client` to `@voltro/protocol`.**
|
|
58
|
+
|
|
59
|
+
It reads the `_tag` that `toRpc` writes, so it now lives beside `toRpc` — one file owning both ends of that contract. Where it used to live had a cost invisible from inside the framework: an app's shared error handler sat in a package that pulled only `@voltro/i18n`, and reading a tag would have meant depending on the entire client package for seven lines. They declined, and kept parsing message strings with a regex — the exact outcome the helper exists to prevent.
|
|
60
|
+
|
|
61
|
+
`@voltro/web` re-exports the client surface, so it loses the symbol too — the same codemod covers an app that imported it from there.
|
|
62
|
+
|
|
63
|
+
Not re-exported from `@voltro/client`: two import paths for one helper is how the next reader learns the wrong one. The transform codemod repoints the import, preserving an alias (`errorTag as tagOf`) and the type-only form, and merges into an existing `@voltro/protocol` import rather than adding a second.
|
|
64
|
+
|
|
65
|
+
While moving it, its doc comment gained the thing that matters at the call site and was only implied before: **`instanceof` does not hold on the client.** What arrives there was decoded from JSON and never constructed, so match on the tag, not on the class. One team read the old wording as a promise that `instanceof` works and was right to say so.
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- **@voltro/protocol, @voltro/cli** — **An auth strategy reaches the app's DataStore, on `input.store`.**
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const sessionStrategy: AuthStrategy = {
|
|
73
|
+
id: 'db-session',
|
|
74
|
+
resolve: async ({ headers, store }) => {
|
|
75
|
+
if (store === undefined) return { kind: 'skip' } // still booting
|
|
76
|
+
const [row] = await store.query(sessions.byToken(headers.authorization))
|
|
77
|
+
return row ? { kind: 'matched', subject: toSubject(row) } : { kind: 'skip' }
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Without it, a DB-backed strategy — a session row, an API-key record, a PAT table — had to open a SECOND connection path beside the framework's, to the same database the request store opens a moment later. One adopter's `auth/db.ts` is 105 lines of exactly that: a second `ManagedRuntime` plus a `MysqlClient`, load-bearing for their session lookup and their ApiKeyStore. Every DB-backed OIDC / SAML / PAT integration rebuilds it, which is what made this a framework gap rather than an app's problem.
|
|
83
|
+
|
|
84
|
+
It is the **same value** `auth.resolveScopes` already receives, through the **same lazy getter** — one ref, two consumers, rather than each caller reaching for the store its own way. That is deliberate: `voltro dev` builds the store AFTER the auth chain and `voltro serve` builds it BEFORE, so a value captured at config time would be `undefined` forever in dev and correct in production. The getter is read ONCE per request, not once per strategy.
|
|
85
|
+
|
|
86
|
+
`store` is `undefined` only while the store is still being built, and on an app with no store — a strategy should `skip` rather than throw. It is the BOOT store, not a request-scoped one: strategies resolve before a request store exists.
|
|
87
|
+
|
|
88
|
+
**Not narrowed to a read-only surface**, and the reason is worth stating: the narrower type would be the better guarantee, `DataStore` is the driver SPI, and giving strategies a different type from the one `resolveScopes` gets would put two views of one object in the same file. A strategy that writes during subject resolution is a design mistake; the type system is not going to catch it for you. Read users / sessions / keys, do not run domain writes.
|
|
89
|
+
|
|
90
|
+
### Fixed
|
|
91
|
+
|
|
92
|
+
- **@voltro/cli** — `voltro dev` terminates when its dev server does, instead of living forever.
|
|
93
|
+
|
|
94
|
+
The supervisor never watched its child die. `runChild` was an `Effect.acquireUseRelease` whose `use` was `Effect.never`, so `proc.on('exit')` existed ONLY in the release path — and that path runs when the fiber is interrupted (a restart, a signal), never when the child exits by itself. Two more places assumed the same thing: `runSupervisor` was typed `Effect<never>`, and `dev.ts` returned `new Promise(() => {})` after starting it.
|
|
95
|
+
|
|
96
|
+
So a boot that aborted — an unreachable database, a refused migration, a failed env gate — left the child dead and the supervisor waiting for a file change that nobody was there to make. Measured, not inferred: one developer machine carried 15 such `voltro dev` process pairs, `ppid=1`, the oldest 7 days old, every one a boot that had failed against a remote database. They hold a watcher and a terminal-less process each; in CI the same shape keeps a runner busy after the job "finished".
|
|
97
|
+
|
|
98
|
+
`use` now awaits the child (`awaitChildExit`) and the supervisor races that against the watch loop, so whichever happens first decides. A restart still does NOT end it — `stopChild` interrupts the fiber, so the deferred is never completed on that path. A child killed by a signal reports `code: null`, which is reported as a failure rather than a clean 0.
|
|
99
|
+
|
|
100
|
+
What a self-exit MEANS then depends on whether anyone is watching, because the two failure modes pull in opposite directions:
|
|
101
|
+
|
|
102
|
+
- **Interactive** (stdout is a TTY) — a crashed boot is something you are about to fix, so the supervisor says so and keeps watching. The next save restarts it, which is what every other dev server does; stopping would throw away the watcher mid-edit and make you retype the command. - **Non-interactive** — nobody is going to fix anything. `voltro dev` exits with the child's code, so a failed boot is a failed command. This is the case that produced the invisible processes, and the one CI actually waits on.
|
|
103
|
+
|
|
104
|
+
A CLEAN exit always stops, watched or not. `VOLTRO_DEV_KEEP_ALIVE=1|0` forces the answer for what the TTY check cannot see — a CI runner with a TTY allocated, or a wrapper that pipes output while a human still watches it — and cannot keep a clean exit alive, which would turn a deliberate shutdown into a hang.
|
|
105
|
+
|
|
106
|
+
Pinned against REAL child processes, because the defect was an Effect that never settled — a stubbed `once('exit')` that resolves is exactly what would have passed while the bug shipped.
|
|
107
|
+
- **@voltro/database** — **A user-facing message that names an API must have one — now checked in CI.**
|
|
108
|
+
|
|
109
|
+
The sibling of the claimed-wiring check. That one asserts a doc comment's claimed caller exists; this one asserts a message's claimed API exists. Same failure shape, worse audience: a doc comment is read by somebody browsing, a refusal by somebody already blocked and looking for the sanctioned way out.
|
|
110
|
+
|
|
111
|
+
It exists for a reported bug that nothing could have caught. The drop-table refusal offered, as its FIRST option, *"chain `.dropped()` on it"* — tables have no such marker, only columns do. Doc SAMPLES are typechecked; message strings are not, and cannot be. Three of one release's reported defects lived in that blind spot.
|
|
112
|
+
|
|
113
|
+
Three rules, each with an unambiguous answer, because a noisy gate is skipped and then costs more than it saves:
|
|
114
|
+
|
|
115
|
+
- a `VOLTRO_*` variable a message tells you to **set** must be read somewhere, - a `` `.method()` `` a message tells you to **chain** must be a callable MEMBER of a published type, - a `--flag` in a `voltro …` instruction must be parsed.
|
|
116
|
+
|
|
117
|
+
The member rule is the one that took two attempts. The first version asked "does this name exist in the public surface" and the motivating bug **passed it**: `dropped` is exported, as a free `dropped()` you write as a column's value. A dotted claim is a claim about something chainable, so a free function and a `readonly dropped?: boolean` data property are both correctly rejected now.
|
|
118
|
+
|
|
119
|
+
**It immediately found a second instance nobody had reported** — the drop-COLUMN refusal also said "chain `.dropped()`", one level down from the reported one, and the real spelling is `<column>: dropped()` as the field's value. Close enough to guess from, which is why it survived.
|
|
120
|
+
|
|
121
|
+
Ships with a `--selftest` that runs first in CI, for the reason the changelog gate has one: a check that has quietly stopped detecting anything still prints green, and green is read as evidence.
|
|
122
|
+
- **@voltro/database** — **Two migration refusals sent people the wrong way** — the worst place for a bad hint, because whoever reads one is already blocked and looking for the sanctioned way out.
|
|
123
|
+
|
|
124
|
+
**The drop-table refusal recommended an API that does not exist.** Its first option was *"add it to your declared set + chain `.dropped()` on it"*. There is no table-level `dropped()` — only the column marker. The recommendation was also the conceptually RIGHT one, which is what made it expensive: the two options that do work are both worse, so a reader picks the one they cannot follow.
|
|
125
|
+
|
|
126
|
+
There is now a real per-table answer: **`VOLTRO_DESTRUCTIVE_OK` accepts a table list**, not just `1`. `VOLTRO_DESTRUCTIVE_OK=old_things` acknowledges the data loss for that table and leaves every other lossy op in the plan blocked. `1` still means all of them — which is rarely what somebody means, and was previously the only way to say anything. A user with one intended drop and three other lossy ops had to acknowledge all four or hand-write a `DROP TABLE` migration, the path 0.14.0's own upgrade note warns against.
|
|
127
|
+
|
|
128
|
+
The message also states why there is deliberately no table marker: a dropped COLUMN leaves a slot worth documenting in the declaration; a dropped TABLE leaves nothing, so the marker would be a dead entry you must remember to delete.
|
|
129
|
+
|
|
130
|
+
**The drop-column refusal never mentioned `renamedFrom`.** It offered "chain `.dropped()`" or "restore the field" — and followed literally on a rename, the first costs exactly the data the user was trying to keep. When the plan drops AND adds columns on the same table, the message now leads with *"did you rename one?"* and names both sides. The evidence was in the plan the whole time.
|
|
131
|
+
|
|
132
|
+
Finding that required fixing a second thing: the footer read only the BLOCKED operations, and an `add-column` is `safe`. The counterpart of a rename was never in the list it was looking at.
|
|
133
|
+
- **@voltro/cli** — **0.14.0's taxonomy codemod renamed two kinds of file it should not have, and a repo that already upgraded carries the damage with a green build.** Both were found by adopters running it on real projects; both are silent — the rename succeeds, the imports are rewritten, nothing throws.
|
|
134
|
+
|
|
135
|
+
**A framework primitive was treated as an undeclared file.** `health.route.tsx` → `health.route.component.tsx`, five times in one app. The codemod kept its OWN list of "suffixes that already carry a contract" instead of reading `fileConventions.ts`, and `.route.` was not on it — the exact drift that module exists to prevent, reproduced inside a file that imports from it. The list is gone; the registry answers now, and it gained `ROUTE_PATTERN` plus a `carriesFrameworkConvention()` every consumer shares.
|
|
136
|
+
|
|
137
|
+
The root cause underneath was worse than a missing entry: `export default defineRestRoute({...})` resolved to the placeholder name `Default`, which starts with a capital, and was counted as a COMPONENT on that basis. A default export is now only evidence of a component when the exported thing is callable — so a convention nobody has registered yet is safe too.
|
|
138
|
+
|
|
139
|
+
**A file with no exports was called a type file.** `test-setup.ts` → `test-setup.types.ts`, while `vitest.config.ts` still named `./test-setup.ts` as a **string**. Not an import, so nothing rewrote it and nothing failed: that suite would have run without its setup and stayed green. Five more went the same way — a registry module, two migration runners, a `.register.ts`, and a code generator whose `export` tokens live inside template strings.
|
|
140
|
+
|
|
141
|
+
`*.types.ts` promises "zero runtime exports", and that promise only means something for a file that exports TYPES. Zero of everything promises nothing, and renames a module whose whole purpose is being imported for effect — where the filename is often the only reference there is. It now requires at least one exported type.
|
|
142
|
+
|
|
143
|
+
**The shipped codemod undoes both**, and can only reach files whose content proves the suffix was wrong: a `.component.` on a name that already carries a framework convention, and a `.types.` on a file that exports nothing at all. It also prints the one thing it cannot fix — references by PATH rather than by import (a vitest `setupFiles`, a tsconfig `include`, a Docker `COPY`) were strings on the way out and are strings on the way back.
|
|
144
|
+
- **@voltro/cli** — Three ways `voltro update` failed on a real adopter's host, none of which we could have found ourselves — each needs a machine we do not have.
|
|
145
|
+
|
|
146
|
+
**The install could not run here, and the refusal left the tree half-upgraded.** Their install runs in a container against its own store. `voltro update` ran the package manager on the host anyway; pnpm refused (it wanted to remove `node_modules` and had no TTY to ask) and exited — after the version bumps were already written and before any codemod ran. That is the state this command's own documentation calls the worst one to be in, and it was reachable by design.
|
|
147
|
+
|
|
148
|
+
`--no-install` now writes the bump and stops, saying plainly that the tree is half-upgraded and naming both remaining steps. The install-failed message points at it too. Note what this is not: a compatibility flag. It is a mode for a host where the install is somebody else's job, and it ends by telling you the job is not done.
|
|
149
|
+
|
|
150
|
+
**The codemod scan exhausted a 12 GB heap, and said nothing about why.** The crash was a bare V8 out-of-memory stack. The scan pruned the directories WE know about — `node_modules`, `dist`, `.turbo` — which cannot cover a project's own heavy ignored trees (a build cache, a data dump, a virtualenv).
|
|
151
|
+
|
|
152
|
+
Inside a git repository the scan now asks git: `git ls-files --cached --others --exclude-standard` is exactly "files this project considers its own", and a codemod rewrites source — source that git ignores is not source we may rewrite. It also removes the traversal, so there is nothing left to exhaust memory on. Outside a repo the walk remains, now with a ceiling that REPORTS which directory to exclude instead of dying namelessly.
|
|
153
|
+
|
|
154
|
+
**"not a Voltro app" was the wrong conclusion.** Said of a directory containing an `app.config.ts`, it sends the reader looking for the wrong problem. Three web apps in a workspace inherited from another tool had their `@voltro/*` dependencies in an ancestor `package.json` — the apps ARE Voltro apps; only the declaration lives elsewhere. With an `app.config.ts` present the message now says that, and names the two ways forward.
|
|
155
|
+
|
|
156
|
+
**A monorepo may keep ONE root `package.json`** with its apps carrying only an `app.config.ts`. Running `voltro update` inside such an app used to say "no package.json at <dir>" — true, and useless. It now recognises the layout, says it is supported, and prints the command with the root already filled in.
|
|
157
|
+
- **@voltro/cli** — **`voltro update --only <id>`**, and a summary that stops contradicting itself.
|
|
158
|
+
|
|
159
|
+
`--only` runs just the named codemods (repeatable or comma-separated; ids are what `--dry-run` prints). The ask behind it: two of three codemods were load-bearing for one app — without them, 52 type errors and 51 routes that 404 — while the third was elective, and the repairing tool refuses on a dirty tree, so there was no way to take the necessary half first.
|
|
160
|
+
|
|
161
|
+
It is deliberately NOT a `--required` flag over a REQUIRED/OPTIONAL axis on each codemod. "Required" would have to mean *this app does not run without it*, and that is a property of the app: `03_pages-suffix` is unavoidable for a project with pages and irrelevant to an api-only one. Marking it on the codemod would encode a guess as a contract. An unknown id is an error listing the ids that ARE available — "it did nothing" and "you typed it wrong" otherwise look identical.
|
|
162
|
+
|
|
163
|
+
**The summary counted only edited files, and renames vanished from it.** The before-snapshot is keyed by PATH, so a moved file has no entry under its new one: a pass that renamed 238 files and edited 9 importers reported `(9 files)`, understating the change by a factor of 26 in the line a user plans around. Moves are now paired by content and reported separately — `(238 renamed, 9 edited)`.
|
|
164
|
+
|
|
165
|
+
**A dry run no longer speaks in the past tense.** It printed `✓ <id> — <title>`, the same line a real run prints. Now `·` and `[would apply]`.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## [0.14.0] — 2026-07-26
|
|
170
|
+
|
|
171
|
+
### ⚠ BREAKING
|
|
172
|
+
|
|
173
|
+
- **@voltro/cli, @voltro/database** — `voltro dev` no longer applies file-based migrations to a REMOTE database unattended.
|
|
174
|
+
|
|
175
|
+
The planner refuses lossy operations without `VOLTRO_DESTRUCTIVE_OK=1`, and its own error text names the way through: "if intentional, set VOLTRO_DESTRUCTIVE_OK=1 OR add a file-based migration". So the documented route around the safety belt had none of its own. Measured, not hypothesised: a file containing `DROP TABLE ... CASCADE` was saved at 15:11; at 15:12:41 the table was gone from a live production database, recorded `appliedBy: boot:dev`. An already-running dev server had picked the file up on its next reboot. Nothing was started, no command was typed, no review happened — writing the file WAS the deployment. What prevented harm was a hand-written guard inside that particular migration; the framework contributed nothing.
|
|
176
|
+
|
|
177
|
+
Local counts as: loopback, private LAN (RFC 1918), `host.docker.internal`, a `.local` / `.localhost` name, a `file:` / `sqlite:` URL, or a BARE hostname (`postgres`, `db`, `voltro-test-mariadb`) — only a container network resolves those, so docker-compose setups are untouched. An unparseable `DB_URL` counts as remote on purpose: guessing "local" wrongly writes to production, guessing "remote" wrongly costs one env var.
|
|
178
|
+
|
|
179
|
+
Three deliberate non-choices. It does **not** detect destructive SQL — in arbitrary SQL that is not decidable, so the gate would be either leaky or noisy; what it separates is *saving a file* from *applying it to production*. It **refuses** rather than skipping quietly — a skipped migration leaves the database in a shape the app does not expect, and the failures that follow point everywhere except at the cause. And it stays **silent when nothing is pending**, so dev against a remote database is unaffected until the moment a file would actually execute against it.
|
|
180
|
+
|
|
181
|
+
`voltro db files`, `voltro db apply` and `voltro serve` are unchanged: an explicit command is already an explicit decision. Escape hatch: `VOLTRO_REMOTE_MIGRATIONS_OK=1`.
|
|
182
|
+
|
|
183
|
+
`@voltro/database` gains `pendingFileMigrationIds(sql, projectRoot)` — the ids that would run next, without running them. The gate needs to name what it is refusing, and must not have executed anything to find out.
|
|
184
|
+
- **@voltro/cli, @voltro/web** — **Only `*.page.tsx` under `src/pages/` is a route.**
|
|
185
|
+
|
|
186
|
+
Pages were the last primitive without a file convention. Every other one carries its type in the name — `*.query.ts`, `*.cron.tsx`, even `*.island.tsx`, which is a *part* of a page — while a page was any `.tsx` that happened to sit under `src/pages/`. Two discovery models, and the positional one made colocation impossible: a component next to its page got a URL.
|
|
187
|
+
|
|
188
|
+
The breakage was silent, which is why this is a correction and not a taste argument. Nobody navigates to an accidental URL in dev, so the route existed, was broken, and said nothing. `voltro build`'s prerender is the first thing that ever evaluates the module — one app carried ~600 accidental routes for months and found out at its first production build, with `SSR received a descriptor with no Component`.
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
src/pages/users/index.page.tsx → /users
|
|
192
|
+
src/pages/users/[id].page.tsx → /users/[id]
|
|
193
|
+
src/pages/(marketing)/pricing.page.tsx → /pricing
|
|
194
|
+
src/pages/users/UserTable.tsx → not a route — colocation is now legal
|
|
195
|
+
src/pages/users/index.page.test.tsx → not a route
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The suffix sits *behind* the segment, so dynamic params, catch-alls and route groups are unchanged. `layout.tsx` / `error.tsx` / `loading.tsx` / `not-found.tsx` keep their exact names — `layout.layout.tsx` is nonsense — but they are now **scoped to route-bearing directories**: a special file in a directory with no `*.page.tsx` at or below it is inert and is reported at boot rather than silently wired. That scoping is not cosmetic. The change *invites* colocation, so a co-located `error.tsx` (a 404 illustration, say) would otherwise install a real error boundary for the whole subtree.
|
|
199
|
+
|
|
200
|
+
**Separately, and independent of the suffix: a page with no default export now fails at codegen.** The suffix declares intent, and intent can be wrong — a `*.page.tsx` with no component is still a broken route. `voltro dev` / `build` / `start` all run the check before serving anything, so the first `voltro dev` says it, naming the file and both fixes (add the export, or drop the suffix). This is the half of the report that actually removes the failure class.
|
|
201
|
+
|
|
202
|
+
**`voltro doctor` gained a page-convention scan**, in both directions: a `.tsx` under `src/pages/` that looks like an unmigrated page (default export, imported by nothing — a real page is only ever found by the router), and a `*.page.tsx` *outside* `src/pages/`, which will never route. The second failure class is created by this change, so it ships with its own check. A third, advisory bucket names a page with no `*.page.test.tsx` beside it.
|
|
203
|
+
|
|
204
|
+
**Migration** is `git mv` and nothing else — page files are not imported, so no import path moves with them. The codemod renames every file that is a route *today*, which is behaviour-preserving by construction. It deliberately does **not** use the tempting rule "pages are the files nobody imports": that rule is right about pages and wrong about the case that costs you a route — a page with a co-located test *is* imported, by its own test, which our own testing guidance encourages. Such a page would keep its name, stop being a route, and 404 with nothing in any build log. Files that something other than a test imports are renamed anyway and then **reported**, so a human decides whether they should instead lose the suffix and become ordinary co-located code.
|
|
205
|
+
|
|
206
|
+
A library that happens to have a `src/pages/` directory (the framework's own `@voltro/devtools-ui` keeps 31 shared page components there) is left alone — the codemod requires an `app.config.ts` at the app root, the same signal the CLI uses.
|
|
207
|
+
- **@voltro/database, @voltro/cli, @voltro/ai, @voltro/plugin-webhooks** — **Reactivity is the default. `.reactive()` is gone; `.nonReactive()` opts out.**
|
|
208
|
+
|
|
209
|
+
A reactive framework whose reactivity is opt-in has the default backwards. Every table now participates in cross-instance change capture, and you write nothing to get it.
|
|
210
|
+
|
|
211
|
+
The old keyword failed in both directions silently, which is why this is a correction rather than a preference:
|
|
212
|
+
|
|
213
|
+
- Written on mysql/mariadb/mssql it did **nothing** — those readers tail every table anyway. A team read its doc ("opt the table into the reactive engine"), found 26 tables without it, and reasonably concluded much of their app was never live. It always was; the doc was wrong, and the investigation was the cost. - Omitted on postgres with `changeStrategy: 'cdc'` it meant a write on one instance **never reached another instance's subscribers**. Correct on the writing pod, stale everywhere else, and perfect in single-instance dev.
|
|
214
|
+
|
|
215
|
+
**`.nonReactive()` turns reactivity OFF — not "off across instances".** The table emits no change events at all: no local subscriber fires, no cross-instance transport carries it. A version that silenced only the cross-instance half would leave every in-process subscription live on a table whose declaration says it is not reactive, which is not what the name says.
|
|
216
|
+
|
|
217
|
+
Implemented at each store's emit, in every dialect — memory, sqlite (and turso, which reuses it), postgres, mysql/mariadb, mssql — with the predicate living once in the table registry. On postgres, mysql/mariadb and mssql it ALSO drops the cross-instance half: no `REPLICA IDENTITY FULL` and no `pg_notify` trigger, excluded from the binlog reader's filter, excluded from the Change Tracking set. The dialect-dependent meaning is gone, not moved.
|
|
218
|
+
|
|
219
|
+
The WRITE is unaffected — this is about notification, never persistence.
|
|
220
|
+
|
|
221
|
+
Keep it for a genuinely hot, genuinely unsubscribed table — an append-only event log, a metrics sink. `REPLICA IDENTITY FULL` widens every UPDATE/DELETE in the WAL and the trigger fires on every write, so opting one of those out is a real saving. Opting out a table a query still reads is not, and `voltro dev` / `voltro serve` warn when you do.
|
|
222
|
+
|
|
223
|
+
The codemod deletes every `.reactive()` call — the behaviour it opted into is now universal, so removing it changes nothing for those tables. Tables that never had it gain the trigger on the next migration; that is the point.
|
|
224
|
+
|
|
225
|
+
**sqlite and turso.** Every table is reactive there too — the store emits its committed deltas inline, exactly as on every other dialect. `.nonReactive()` has nothing to suppress on them, because neither has a cross-instance transport to opt out of.
|
|
226
|
+
|
|
227
|
+
For sqlite that is honest: a local file is one process. **Turso is not**, and that is now said at boot. It reuses the SQLite store, so it has no cross-instance change capture at all — while `libsql:` / `https:` / `wss:` and the embedded-replica mode exist precisely to point several app instances at one primary. On a shared turso, every table's subscribers see only their own instance's writes, regardless of any flag. Somebody who chose a distributed database and a reactive framework has every reason to assume otherwise, so `voltro dev` / `voltro serve` warn when the URL is a shared one. A local `file:` turso stays silent.
|
|
228
|
+
|
|
229
|
+
**Type-surface note.** The `Reactive` type parameter on `Table<…>` now defaults to `true`, so exported table constants in `@voltro/ai` and `@voltro/plugin-webhooks` are typed `Table<…, true, …>` where they read `false` before. Code that only USES those tables is unaffected — the parameter is not part of any call signature. Code that ANNOTATES one by hand (`const t: Table<'x', …, false, never> = …`) has to drop the explicit parameter or write `true`; the framework's own annotations were updated the same way.
|
|
230
|
+
- **@voltro/cli** — `auth.resolveScopes` receives the app's DataStore.
|
|
231
|
+
|
|
232
|
+
The hook shipped with `(subject, { headers, clientId })`, which is missing the one thing a role-based resolver needs. A role lives in the database — the reporting app's is two joins deep — so reaching it meant opening a SECOND connection path beside the framework's, to the same database the request store opens a moment later. That is why an otherwise willing adopter could not adopt, and it made the hook's stated purpose unreachable by its own signature.
|
|
233
|
+
|
|
234
|
+
The second argument now carries `store`. It is the BOOT store, not a request-scoped one, and it arrives through a lazy ref because strategies resolve before a request store exists — `undefined` only while the store is still being built. Wired identically under `voltro dev` (which builds the store after the auth chain) and `voltro serve` (which builds it before).
|
|
235
|
+
- **@voltro/client** — **Tracking catalogues are typed against the component's props.**
|
|
236
|
+
|
|
237
|
+
```tsx
|
|
238
|
+
interface ButtonProps { readonly plan: 'free' | 'pro'; readonly onClick: () => void }
|
|
239
|
+
|
|
240
|
+
const spec = defineTracking<ButtonProps>('Checkout', {
|
|
241
|
+
onClick: (props) => ({ event: 'checkout.started', plan: props.plan }),
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const tracked = useTracking(spec, props, sink)
|
|
245
|
+
return <button {...tracked}>Checkout</button> // now actually typechecks
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Found by using the primitive on real code for the first time. Two defects, both invisible until then:
|
|
249
|
+
|
|
250
|
+
- a payload builder could only reach `props['plan']` as `unknown` and cast it — in the ONE file whose job is to state exactly what leaves the browser. A cast is the last thing that belongs there. - `useTracking` returned `Record<string, unknown>`, so the usage our own docs show — `<button {...tracked}>` — did not typecheck under `strict`. The sample survived because it was never compiled against a typed handler.
|
|
251
|
+
|
|
252
|
+
The type parameter defaults to the old untyped bag, so an untyped catalogue is unchanged. What breaks is the **return type narrowing** from `Record<string, unknown>` to the props type: `tracked['notAProp']` was `unknown` and is now an error, and `(tracked['onClick'] as () => void)()` no longer needs its cast. A `manual` codemod, because a transform cannot typecheck and so cannot tell a now-redundant cast from one that was hiding a real disagreement.
|
|
253
|
+
- **@voltro/cli** — **The web file taxonomy — seven contract suffixes, each enforced.**
|
|
254
|
+
|
|
255
|
+
Every entry had to pass one test: *does another file's correctness depend on this file keeping its promise?* If yes the promise belongs in the name, because a contract you cannot see is one you break without noticing. If no it is a category, and categories are read out of the file.
|
|
256
|
+
|
|
257
|
+
| Suffix | Promise | |---|---| | `*.component.tsx` | exactly one component (+ types) | | `*.component.ui.tsx` | one component, **reads only** — never writes | | `*.hook.ts` | exactly one `use*` hook (+ types) | | `*.types.ts` | zero runtime exports | | `*.internal.ts` | only its own directory subtree may import it | | `*.fixture.ts` | no production path may reach it | | `*.tracking.ts` | analytics is called nowhere else |
|
|
258
|
+
|
|
259
|
+
`voltro doctor` enforces all of them; `voltro doctor --json` emits every finding.
|
|
260
|
+
|
|
261
|
+
**`*.component.ui.tsx` may read and must not write.** `useT`, `useCan`, `usePermissions` stay allowed on purpose — threading translations and permissions through props is prop-drilling, and it makes every call site worse without making the component more portable. Importing a write hook (`useMutation`, `useAction`, `useUpload`, …) is the violation, because a component that can mutate cannot be rendered ten thousand times in a list, reused across features, or prerendered without first reading its source. That property is what its callers rely on. It must also be *reached* from a `*.component.tsx`, another `*.component.ui.tsx`, or a page: an unrendered presentational component is carried, reviewed and refactored forever without reaching a user.
|
|
262
|
+
|
|
263
|
+
**`*.types.ts` having no runtime export is a guarantee, not tidiness.** It makes importing the module free in the bundle *and* makes it impossible for it to participate in a runtime import cycle — and in a large codebase the second one is the valuable half, because a cycle is invisible until it throws.
|
|
264
|
+
|
|
265
|
+
**`*.tracking.ts` confines the event catalogue.** The rule is global: `defineTracking(...)` may be called from nowhere else, so every event name, property bag and decision about which fields leave the building lives in files you can list. A component wires one up with `useTracking(spec, props, sink)` — it NAMES a spec, it never declares one.
|
|
266
|
+
|
|
267
|
+
`useTracking` itself is deliberately not confined: it is a hook, so it must run inside a component and could not be moved into a plain module. A rule nobody can satisfy is a rule everybody disables. The payoff stands either way — "what do we send to third parties" becomes a file listing rather than an archaeology project, which is the only form in which that question can be answered on demand.
|
|
268
|
+
|
|
269
|
+
**What deliberately has no suffix.** A generic "one component per file" rule would be worth enforcing everywhere, so tying it to a rename would make it opt-in: less coverage for more cost. The shape rules fire only on files that *declared* the contract. There is also no `*.store.ts` — nothing in the framework depends on a store, so the suffix would promise nobody anything. Suffixes follow primitives, never the reverse.
|
|
270
|
+
|
|
271
|
+
**Migration.** The codemod renames what the exports decide unambiguously: one component → `*.component.tsx`, one hook → `*.hook.ts`, no runtime exports → `*.types.ts`. Imports travel with the file. It refuses two cases on purpose — a file exporting a component *and* a hook (the one the taxonomy most wants split, and no codemod can decide which half keeps the name), and `*.component.ui.tsx`, which is never inferred because "presentational" is a promise about what a component *may* do, not an observation that it currently does not.
|
|
272
|
+
|
|
273
|
+
**The rules apply to code you AUTHOR, never to vendored code.** A shadcn component arrives via `npx shadcn add`, follows shadcn's conventions, and is overwritten by the next `add` — renaming it breaks their convention, is undone next run, and leaves the directory half-migrated the moment one file fails to classify. Our own devtools dashboard demonstrated all three before this landed.
|
|
274
|
+
|
|
275
|
+
A directory is exempt when the app's `components.json` names it (`aliases.ui` only — `aliases.components` is where your own components live too, and honouring it silenced the taxonomy across our whole dashboard) or when it carries a `.voltro-vendored` file whose first line names the source. The marker is a file with a reason rather than a config list on purpose: a config list is invisible from the directory it exempts and quietly becomes where people put their own code to silence a rule. Every honoured exemption is printed, so the escape hatch is never silent.
|
|
276
|
+
|
|
277
|
+
### Added
|
|
278
|
+
|
|
279
|
+
- **@voltro/protocol, @voltro/cli** — Actions can declare `source` and `target`, and `orphan/unread-table` stops advising deletion on a conclusion it cannot support.
|
|
280
|
+
|
|
281
|
+
An action is non-transactional external I/O, and it very often touches a table on the way — a cache it fills, a job row it stamps. There was no slot to declare that, so every such table was invisible: `voltro check` reported one that five action paths read and wrote as an orphan, and the suggested fix was **remove the table**. A wrong finding is bad; a wrong finding whose remedy is destructive is worse.
|
|
282
|
+
|
|
283
|
+
Two halves. `defineAction` now takes the same `source` / `target` a query and a mutation take, so the answer can be declared. And while any action declares neither, the orphan rule says so and asks for the declaration instead of proposing a delete — absence of a declaration is not a declaration of absence, the same distinction the manifest already draws for guards.
|
|
284
|
+
- **@voltro/cli** — **`*.client.ts` — declare a shared file browser-safe, and have it checked.**
|
|
285
|
+
|
|
286
|
+
`*.server.ts` works because it declares a *permission*, not a fact: "this file may import `node:*`". An import graph cannot derive that — it can tell you what a file imports, never what it is allowed to import. `*.client.ts` is the mirror: *I, and everything I transitively import, am browser-safe.* `voltro dev` walks the claim at boot with the same walker and forbidden list the rpcGroup guard uses, and refuses to start if it is false — and **`voltro check` walks it too**, reporting `client/not-browser-safe` with the import chain.
|
|
287
|
+
|
|
288
|
+
The check matters more than it sounds: `voltro dev` only sees the app it boots, so a marker in a WEB app or in a package no api boot touches was a promise nobody ever read. CI runs `voltro check`, which walks every marked file in the project regardless of which app owns it.
|
|
289
|
+
|
|
290
|
+
It exists for the trap this framework records as its worst, which is **transitive**: a descriptor imports a shared `lib/` helper, that helper also imports the `database` handle, and the whole server graph lands in the browser bundle. The rpcGroup guard already catches that — but only once some descriptor happens to reach the file, and it reports a forty-module chain you read backwards to find the one shared file that should never have touched the database. The marker moves the failure to that file, at the moment it is written.
|
|
291
|
+
|
|
292
|
+
An unmarked file makes no claim, and that is deliberate. This is not a `*.component.tsx`-style label: those describe what a file already obviously is, nothing would enforce them, and a convention nothing enforces gets half-adopted — after which an unmarked file means nothing at all.
|
|
293
|
+
|
|
294
|
+
**`voltro check` gained `rbac/unenforced-scope`** — a scope a role *grants* that no handler ever guards on.
|
|
295
|
+
|
|
296
|
+
`rbac/unknown-scope` already read the registry the other way (a guard naming a scope nobody grants), and that direction is easy because the guard is a thing you can look at. This one has no artefact at all: you cannot grep for an authorization check that was never written, which is exactly why it survives review. One app modelled `api-keys:write` in its role catalogue, complete and reviewed, and no handler checked it — any member could mint a shared credential, and nothing failed. Tests pass when an authorization check is missing.
|
|
297
|
+
|
|
298
|
+
A **warning**, not an error: a plugin route may enforce it internally (the graph cannot see inside a plugin), a REST route carries its own guards, or it may be a UI-affordance scope `useCan` reads to hide a button with no server check by design. All three are legitimate. Not knowing which is not.
|
|
299
|
+
|
|
300
|
+
Note what this deliberately is NOT: a `*.guard.ts` file convention. Guards are already declarative and already live on the descriptor, beside the thing they protect. Moving them to a separate file would make them *less* discoverable, and their absence from the filesystem would mean nothing — failing the same test `*.client.ts` passes.
|
|
301
|
+
- **@voltro/cli** — **`voltro check` enforces the file conventions — CI, not just the doctor.**
|
|
302
|
+
|
|
303
|
+
`voltro doctor` is what a person runs when something feels wrong. `voltro check` is what CI runs on every commit. A contract enforced only by the first is enforced on the days nobody is looking, which is every day — so the taxonomy and the page convention now produce `check` diagnostics and set its exit code.
|
|
304
|
+
|
|
305
|
+
What FAILS a build: a `*.types.ts` with a runtime export, a foreign import of an `*.internal.ts`, a fixture reachable from production code, a `*.component.ui.tsx` that writes, a store mirroring server state, two stores in one file, and a `*.page.tsx` outside `src/pages/` (which can never route).
|
|
306
|
+
|
|
307
|
+
What is REPORTED and does not fail: a missing test beside a file, an orphaned presentational component, and an unsuffixed `.tsx` under `src/pages/`. The last one fires on a correct co-located component often enough that blocking on it would be wrong, and a gate that blocks on a missing test gets disabled within a week — taking the real rules with it.
|
|
308
|
+
|
|
309
|
+
Vendored directories are exempt here exactly as they are in the doctor: the rules apply to code you author.
|
|
310
|
+
- **@voltro/client, @voltro/web** — **`defineStore` — client state that is not server state.**
|
|
311
|
+
|
|
312
|
+
Server state already had a home: a subscription *is* live server state, and it stays live. What had none was the rest — which rows are selected, which wizard step you are on, the draft you have not submitted. Without a primitive for it a team reaches for zustand or jotai, which is a **parallel runtime** — the exact thing the framework's own guidance tells them not to bring. Shipping nothing here was never neutrality; it was an instruction to import something.
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
const wizard = defineStore('wizard', () => ({ step: 0 }))
|
|
316
|
+
|
|
317
|
+
wizard.use((s) => s.step) // the global instance
|
|
318
|
+
wizard.use((s) => s.step, { key: orderId }) // one instance per order
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**There is no `useStore()` that hands back the whole state**, because it would be used, and a component holding the whole state re-renders on every change to any field. Reads go through a selector or they do not happen — that is the only way "no needless re-renders" is a property rather than an aspiration. Proven against a real renderer, not argued: a component reading `coupon` does not re-render when `note` changes, and one selecting `items.length` does not re-render when `['a']` becomes `['b']`.
|
|
322
|
+
|
|
323
|
+
**Scoping is by KEY, not by a Provider.** A Provider re-renders every consumer when its value identity changes, whether or not that consumer reads the field that moved — that *is* the context-hell mechanism, so putting a store behind one would reintroduce the problem in nicer clothing. An instance is addressed by a key the caller already has (an order id, a table id, a URL param), which is also the model the framework uses everywhere else: `useSubscription('orders.list', { orgId })` is keyed by input, not by tree position. One read form, an optional key.
|
|
324
|
+
|
|
325
|
+
**SSR seeding adds no new channel.** `seedStore(wizard, { step: 2 }, { key })` is callable anywhere on the server during a render, and the value rides the hydration payload the router already writes — no `dehydrate()` to remember and no `hydrate()` to forget, because a step you can forget is a step somebody will. Calling it on the client **throws**: a silent no-op would leave the store empty in the browser and full on the server, which surfaces as a hydration mismatch that reads like a React bug.
|
|
326
|
+
|
|
327
|
+
Request scoping is real, not assumed. Two requests interleave at every `await`, so a module-level "current bag" would put one request's values into another's document; the scope is `AsyncLocalStorage`, installed by the server-only `@voltro/web/ssr` entry (the client package is loaded by browsers and cannot import `node:async_hooks`, so it exposes a resolver and the server supplies the scoping). A test drives two interleaved renders and asserts neither sees the other's seeds.
|
|
328
|
+
|
|
329
|
+
`withStoreSeeds` is a member of the shared `SsrHelpers` contract rather than an ad-hoc import, so a new boot path cannot quietly omit it — the type is what `voltro dev`, `voltro start` and `voltro build` all resolve against.
|
|
330
|
+
|
|
331
|
+
Three details that are load-bearing rather than incidental: `initial` is a **function**, so keyed instances never alias one object (the bug where editing order A also edits order B, found weeks later); the notify loop iterates a **copy**, so subscribing from inside a listener cannot mutate the set mid-iteration; and a `set` whose value is unchanged wakes **nobody**, because a form re-submitting the same draft is the cheapest needless re-render there is.
|
|
332
|
+
|
|
333
|
+
**All three SSR emitters open the scope**, and a lockstep test keeps that true. `voltro dev`, `voltro start` and `voltro build` each call `beginStoreSeeds()` once per render — `enterWith`, not a callback wrap, because `renderPageForRequest` spans hundreds of lines with early returns and a streaming branch that returns from the middle, and restructuring production render code buys the scope nothing. The prerender opens a FRESH scope per artefact: one scope for the whole build would put every page's seeds into every page's document. The seeds are folded into the payload inside `renderRouterStateScript` — one place, which every emitter already calls, so a seed cannot be collected in dev and dropped in production.
|
|
334
|
+
|
|
335
|
+
**`*.store.ts` joins the file taxonomy**, now that something depends on it: the seed pass and the devtools address a store by NAME, so a file holding two of them breaks something other than itself. `voltro doctor` enforces one `defineStore` per file, and reports a store that reads a subscription — detected by the CALL, not by field names, because a store legitimately holds an `orderId` and guessing from names would fire on correct code.
|
|
336
|
+
|
|
337
|
+
**The server renders the seeded value too** — and that is the correctness of seeding, not a refinement of it. Store instances are module-level and shared by every concurrent request, so a seed must never be written into one (request A's wizard step would appear in request B's document). But a server rendering the UNSEEDED instance while the client applies the seed before hydrating produces a mismatch on every seeded page — the exact failure this feature exists to prevent. So the two sides read different sources and arrive at the same value: the server's `getServerSnapshot` reads through the request-scoped bag, the client reads the instance `mount()` already seeded.
|
|
338
|
+
|
|
339
|
+
It took an end-to-end test to see it. Six unit tests were green while React threw `Hydration failed` on every seeded render, because a mismatch is a class no server-side assertion can observe — the same reason `hydrateLoaderData.test.tsx` exists.
|
|
340
|
+
|
|
341
|
+
**Computed selectors, memoised, with the footgun turned into a warning.** The re-render promise held only for selectors returning a PRIMITIVE. One returning an object or a derived list — the exact shape a computed value has — hands back a fresh reference every call, so `Object.is` reports "changed" forever and the component re-renders on every change to any field. Measured before the fix: an object selector re-rendered on an unrelated `set`, and so did `items.filter(…)`. A promise that fails precisely in the case it was sold for is worse than no promise.
|
|
342
|
+
|
|
343
|
+
`{ equals: shallow }` fixes it, the result is cached so an equal value keeps its PREVIOUS reference (which is what makes React skip the render rather than merely recompute), and the selector is not re-run at all while the state object is unchanged. In dev the framework detects the case — a value that would have compared equal one level deep but is not identical, which is exactly the wasted re-render and nothing else — and warns ONCE, naming the fix.
|
|
344
|
+
|
|
345
|
+
**Keyed instances are released with their last subscriber.** They were created on demand and nothing ever removed them: a table keyed by row id accumulated one per row EVER rendered — measured at 1000 live after 1000 keys. The drop is deferred by a macrotask so a remount keeps its state (StrictMode double-invokes, and a route change can unmount and remount the same key within a tick); `{ retain: true }` opts out for state that must survive navigating away.
|
|
346
|
+
|
|
347
|
+
**Every write passes through one seam**, which is where inspection and undo come from. Instrumenting `set` rather than shipping a declared `actions:` bag is a coverage decision: a declared API only sees the writes somebody remembered to declare, and the write that causes the bug is the one written in a hurry, inline, in an event handler.
|
|
348
|
+
|
|
349
|
+
`storeHistory()` / `subscribeStoreHistory()` expose a bounded feed of `{ store, key, label?, prev, next, at }` — bounded because an unbounded log leaks in exactly the long-lived sessions where it would be useful. `store.set(next, key, 'checkout.applyCoupon')` labels a write for that feed. `store.undo(key?)` / `store.redo(key?)` walk an instance's history, with `canUndo` / `canRedo` for the buttons. Restoring is by IDENTITY rather than a merge (a merge would leave behind fields a later write added — a state nobody ever wrote), an undo never becomes undoable itself, and a NEW write after an undo drops the redo tail, which is what every editor does.
|
|
350
|
+
|
|
351
|
+
The first version had no redo, because `undo` SPLICED the entry out of the log: nothing was left to step forward to, and the devtools panel had to invent a second model for the same idea. Both are cursors over intact histories now — the store's per instance, the panel's over the global log.
|
|
352
|
+
|
|
353
|
+
**Battle-tested against the days it is used badly**, which is where a state library is actually judged. A hostile-conditions suite covers tearing between two readers in one commit, a write from inside a listener, a write during render, unsubscribing mid-notification, a thousand subscribers, a thousand keyed instances, a listener that throws, NaN fields, and the awkward corners of shallow equality. Two of those found real defects:
|
|
354
|
+
|
|
355
|
+
- **`set` compared IDENTITY, so a partial merge never short-circuited.** A merge always builds a new object, which meant `set({ step: 2 })` twice with the same 2 woke every subscriber both times — and a listener that wrote could never converge, producing a stack overflow. `set` now compares one level deep, so setting the same values again is free. - **A non-converging listener blew the stack** with an error naming nothing. A depth guard now reports it at the point the loop is still legible.
|
|
356
|
+
|
|
357
|
+
And the piece no jsdom test could reach: the fixture app's layout loader seeds a store, and the dev-SSR boot test asserts the SEEDED value in the server HTML. That proves the seed bag the CLI opens and the `seedStore` the app module calls land on the same `@voltro/client` instance under Vite's dev transform — two copies would throw "outside a server render", green in every unit test and fatal on the first real page. It caught an ORDERING defect immediately: the scope was opened AFTER `buildSegmentChain`, so every seeding layout loader threw and `voltro start` answered `server error` while dev fell through to the SPA shell. The build path happened to be ordered correctly and passed.
|
|
358
|
+
|
|
359
|
+
**A Stores tab in the `voltro dev` overlay, with time travel.** Every defined store with its live state (global and per key), a feed of every write — store, key, label, and the fields that actually changed — and `◀ Back` / `Forward ▶` that restore the state as it was before or after each one. The state a component reads moves with it.
|
|
360
|
+
|
|
361
|
+
This is what putting client state IN the framework buys: no extension, no connector, no version to match. The panel is just another subscriber to the seam every write already passes through, so it sees writes made by code that never heard of devtools, on any machine.
|
|
362
|
+
|
|
363
|
+
Travel is deliberately NOT built on `undo`: undo is a stack that CONSUMES entries, so stepping forward again would be impossible. It moves a position over an intact log instead. The first version tracked "the entry we are parked on" and could not tell "stepped back to the beginning" from "live" — Forward was disabled exactly when it was needed. A count of applied writes has no such ambiguity.
|
|
364
|
+
|
|
365
|
+
`storeHistory()` returns a STABLE reference until the log changes, because `useSyncExternalStore` requires a cached snapshot and a fresh array per call sends any subscriber into an infinite render loop. Our own panel hit that within a minute of being written, so the safety lives in the API rather than in a note every consumer has to read.
|
|
366
|
+
|
|
367
|
+
**Verified in a real browser**, because three of the store's claims cannot be settled anywhere else. `scripts/browser-client-store.mjs` drives chromium against the fixture app and checks: the seeded value is in the FIRST PAINT with **JavaScript disabled** (the only way to prove the server rendered it rather than the client filling it a tick later); React reported no hydration mismatch (a mismatch is a console error in a browser and nothing anywhere else — which is exactly how the seed once shipped rendering 0 on the server and 7 on the client); and a component reading a DIFFERENT field of the same store does not re-render when the first one moves, counted in the DOM because a render count is not observable from outside a page any other way. Writes, undo, redo and the redo-tail truncation are all exercised through real clicks.
|
|
368
|
+
|
|
369
|
+
It caught two fixture defects on its first run, one of them the trap the docs name: the layout seeded the GLOBAL instance while the page read a KEY.
|
|
370
|
+
- **@voltro/testing, @voltro/cli** — **`ctx.webhooks` exists in the test harness, and `voltro test` stops collecting `e2e/` specs.** Both were found by running the starter's own suite, which had been red on both counts.
|
|
371
|
+
|
|
372
|
+
`ctx.webhooks` is a field PRODUCTION supplies (`makeAppContextBuilder`, when the webhooks plugin is configured) and the harness did not — so a mutation written the documented way, `useWebhooks(ctx).emit(todoCreated, {...})`, threw "`ctx.webhooks` is not set" in every unit test. The only way to test one was to hand-roll a context, and the hand-rolled version in the starter was itself broken: it assigned through a cast onto the OUTER context while the handler runs against the transaction context `invoke` derives, so it recorded an emission the handler never made. Every derived context now shares one `MockWebhooks`:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
await invoke(createTodo, handler, { title: 'hi' }, ctx)
|
|
376
|
+
expect(ctx.webhooks.last('todo.created')?.payload).toMatchObject({ title: 'hi' })
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
It records; it does not deliver, sign, or consult subscriptions — a unit test asks what the handler emitted, and delivery is covered where the plugin lives.
|
|
380
|
+
|
|
381
|
+
**`e2e/` belongs to `voltro e2e`.** Its specs drive a browser through tsx against a booted api + web and define no vitest suite, so `voltro test` collected them and reported "No test suite found" — a red run for an app laid out exactly as the framework asks. The exclusion EXTENDS vitest's defaults rather than replacing them (vitest does not merge `exclude`, so a bare glob would silently re-admit `node_modules` and `dist`), and a user-supplied `--exclude` still wins outright.
|
|
382
|
+
|
|
383
|
+
`voltro test` REPORTS every spec it skipped, on every run. A project that had real vitest specs under `e2e/` would otherwise just start running fewer tests and still print green — a silent cap is worse than the red run this replaced, because nothing says it happened. The shipped codemod prints the same thing during `voltro update`, and only for a project that actually has such files.
|
|
384
|
+
- **@voltro/client** — **`store.batch(label, fn)` — many writes, one meaning.**
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
checkout.batch('applyCoupon', () => {
|
|
388
|
+
checkout.set({ coupon })
|
|
389
|
+
checkout.set({ total: recompute(coupon) })
|
|
390
|
+
})
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
One notification, one devtools entry named `applyCoupon`, **one undo step**. Without it that action is three of each: Ctrl-Z walks back through a third of a change at a time, and the feed shows three anonymous writes instead of the thing that happened. React batches the re-*renders* on its own — it cannot batch the meaning, and undo and the devtools feed both read the meaning.
|
|
394
|
+
|
|
395
|
+
**A throwing callback rolls back every write it made.** Nothing was announced yet, so an action that fails halfway cannot leave the half-applied state that is the usual reason people reach for a transaction. Writes that cancel each other out record nothing at all. A nested batch joins its parent.
|
|
396
|
+
|
|
397
|
+
**An `async` callback is a hard error, not a warning.** Everything after its first `await` would land outside the batch — writes escaping one at a time, a rollback covering only the synchronous head, and a devtools entry that lies about what the action did. The error says what to do instead: await first, then batch the writes.
|
|
398
|
+
- **@voltro/client** — *(`apiSurface: compatible` — `defineStore` gained an OPTIONAL third parameter and `StoreHandle` gained a member. Every existing call site compiles unchanged; the handle is only ever obtained from `defineStore`, never constructed.)*
|
|
399
|
+
|
|
400
|
+
**`defineStore(..., { persist })` — client state that survives a reload.**
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
export const filters = defineStore(
|
|
404
|
+
'inbox:filters',
|
|
405
|
+
() => ({ status: 'open', sort: 'newest' }),
|
|
406
|
+
{ persist: { key: 'inbox:filters', pick: (s) => ({ status: s.status }) } },
|
|
407
|
+
)
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Every hand-rolled version of this gets the same three things wrong, so the framework version does them and the tests pin them:
|
|
411
|
+
|
|
412
|
+
- **The stored value is merged over `initial()`, not substituted for it.** Add a field and every returning user otherwise has state missing it — `undefined` where the type promises a string. - **`migrate` returning `undefined` DISCARDS the value.** A stale draft is an annoyance; a half-migrated one is a bug report nobody can reproduce. - **Every storage touch is guarded and wrapped.** The module is imported by the server render too, and Safari in private mode throws on *reading* `localStorage`. A store that throws at import time takes the page with it.
|
|
413
|
+
|
|
414
|
+
Only the **global** instance persists — a keyed instance is per entity, and writing every key into one bucket grows without bound.
|
|
415
|
+
|
|
416
|
+
**A persisted store on a server-rendered page hydrates against the SERVER value.** The server has no `localStorage`, so it renders `initial()` and the stored value lands in the commit right after hydration. Without that split, every returning user got a hydration mismatch — a flash plus a console error that reads like a React bug. `get()` is not deferred, only the render.
|
|
417
|
+
- **@voltro/database, @voltro/cli** — Three diagnostics, each for a failure that had already happened to somebody.
|
|
418
|
+
|
|
419
|
+
**The database disagrees that a table is reactive.** On postgres, reactivity is carried by a per-table trigger, and the schema fingerprint covers columns — not triggers. A restored dump, a hand-run `DROP TRIGGER`, or a table migrated under a release that installed none all leave the schema "up to date" and the trigger absent, with subscriptions silently not reaching other instances. The boot now compares the two and names the tables, including the reverse case: a `.nonReactive()` table still carrying a trigger keeps paying `REPLICA IDENTITY FULL` and a NOTIFY on every write for a subscription nobody receives. Reported, never repaired — `voltro db apply` owns DDL, and a boot that quietly re-created triggers would be a boot doing migrations.
|
|
420
|
+
|
|
421
|
+
**`apiKeys: true` with no issuance scope declared anywhere.** The management routes gate on `apikeys:issue:self|org|other`. If no role declares one, the capability is switched on and reachable by nobody: every issue request fails its guard, which reads as a permissions bug in the app rather than a missing declaration. The two halves live apart — the flag in `app.config`, the scopes in a role map — and neither side can see the other.
|
|
422
|
+
|
|
423
|
+
**A handler that writes `ctx.request.subject.id` with no guard.** The highest-yield finding from a downstream migration: seven per-user mutations with no authentication check at all, each writing a `string | null` subject id into a NOT NULL column, so an anonymous caller reached the database and got `Failed to execute statement` instead of a typed refusal. Their compiler only surfaced it once the write became typed; `voltro doctor` now finds the shape directly. Narrow on purpose — the subject id must be read, a write must be present, and the file must name no guard at all. It strips comments and strings first, so a reassuring note about a guard that is not there does not clear it.
|
|
424
|
+
|
|
425
|
+
### Fixed
|
|
426
|
+
|
|
427
|
+
- **@voltro/cli** — Two more places where the framework wrote or looked in the wrong place.
|
|
428
|
+
|
|
429
|
+
**`voltro build` rewrote `.gitignore` even when nothing was missing**, and rebuilt it from `filter(l => l.trim() !== '')` — silently deleting every blank line from a file a human maintains and git tracks. Where the file was not writable, an identical-content rewrite aborted the build. It now checks first, APPENDS rather than re-emitting, and treats a failure as a tidiness miss rather than a reason to fail a production build.
|
|
430
|
+
|
|
431
|
+
**`voltro doctor` walked a hand-kept list of directories.** A consumer's `schedules/…cron.tsx` was never scanned and they diagnosed it as the `.tsx` extension; the extension was always handled — `schedules/` simply was not on the list. That is the third whitelist in this codebase to drift, so it is gone: the scan walks the app root recursively with the existing prune list, and reports the directories that actually contributed.
|
|
432
|
+
- **@voltro/cli** — The observed-graph check no longer reports "never read" from an empty recording.
|
|
433
|
+
|
|
434
|
+
A procedure can be recorded as having RUN while no table access was captured for it, and "ran and touched nothing" is then indistinguishable from "ran and nothing was recorded". A consumer saw `edges: []` with three procedures in `exercised`, and every one was reported as declaring a source it never read — including a handler that demonstrably reads its table.
|
|
435
|
+
|
|
436
|
+
This is the distinction the manifest already draws for guards, where omitting the field made "no authorization" indistinguishable from "not reported". For reads it had collapsed again. With zero captured edges nothing is claimed, and the procedure is reported in a third bucket alongside `unexercised`. Coverage excludes it too — counting it would overstate the denominator in precisely the run where the recorder produced nothing.
|
|
437
|
+
- **@voltro/database, @voltro/cli** — `.reactive()` says what it does, and the framework says which dialect case you are in.
|
|
438
|
+
|
|
439
|
+
The keyword is named for a general capability and implements one dialect's transport detail, and both ways of getting it wrong were silent:
|
|
440
|
+
|
|
441
|
+
- **Declared where it does nothing.** On mysql/mariadb the ROW-format binlog reader tails every table; on mssql Change Tracking is configured with the whole app table set; sqlite is single-process. The flag is inert on all of them. Its doc said "opt the table into the reactive engine", which is false — every table is already in it, because the store emits committed deltas inline. A team on MariaDB read that, found 26 tables without the flag and 44 query descriptors reading them, and reasonably concluded a large part of their app was never live. It always was; the investigation was the cost. - **Missing where it is required.** On postgres with `changeStrategy: 'cdc'`, only a `.reactive()` table gets the `pg_notify` trigger the CDC consumer listens on. A table without it never propagates a write to another instance's subscribers — correct on the writing pod, stale everywhere else, and perfect in single-instance dev.
|
|
442
|
+
|
|
443
|
+
`voltro dev` and `voltro serve` now name whichever case applies: an info line when the flag is inert on this dialect (including that nothing is missing), and a warning listing the tables a query reads that lack it under postgres+cdc. A warning rather than a refusal, because the framework cannot tell from inside one process whether a second one exists.
|
|
444
|
+
|
|
445
|
+
Also corrected: the DSL doc, and a `reactiveTables` variable in the CDC wiring that was actually every app table and consulted no flag at all.
|
|
446
|
+
- **@voltro/cli** — `voltro test`'s tsconfig reader destroyed any config carrying a `@/*` alias, so the alias derivation shipped in the previous release was correct and never ran.
|
|
447
|
+
|
|
448
|
+
The comment stripper was a regex. It read the `/`+`*` inside the alias key `"@/*"` as an opening block comment and closed it on the `*`+`/` inside an `include` glob like `src/**`, deleting everything between — `paths` included. The parse then failed, and an unparseable tsconfig degrades to "no aliases" by design, so the whole thing was silent. Essentially every real tsconfig has both an `@/*`-style alias and a `**` glob, which made this every real tsconfig.
|
|
449
|
+
|
|
450
|
+
It now scans string state in one pass instead of regexing over strings, and the trailing-comma pass does the same — a `,` inside a string is not a trailing comma either. Reported with a five-line comment-free repro, which is exactly what made it obvious that comments were never the trigger.
|
|
451
|
+
|
|
452
|
+
---
|
|
453
|
+
|
|
42
454
|
## [0.13.0] — 2026-07-25
|
|
43
455
|
|
|
44
456
|
### ⚠ BREAKING
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/plugin-atlassian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Jira + Confluence plugin — JiraService + ConfluenceService over the Atlassian REST/Greenhopper/Agile APIs, with a pluggable per-subject credentials resolver (PAT), transient retry + Retry-After, timeouts, an SSRF-guarded PAT-free avatar proxy, and optional response caching via @voltro/cache.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"node": ">=24.0.0"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@voltro/integration-http": "0.
|
|
46
|
-
"@voltro/protocol": "0.
|
|
45
|
+
"@voltro/integration-http": "0.15.0",
|
|
46
|
+
"@voltro/protocol": "0.15.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"effect": "^3.21.4"
|