@voltro/plugin-comments 0.54.0 → 0.55.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 CHANGED
@@ -39,6 +39,144 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.55.0] — 2026-08-27
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/protocol, @voltro/client, @voltro/runtime, @voltro/cli** — **A multi-reference field now flips as instantly as a scalar one.** A mutation target's declared `relations:` reconciled the junction inside the server's transaction and nothing else: the client learned about the link change only when the delta came back. On the same submit, the renamed title flipped immediately and the assigned stores did not — the half of the promise that was never stated.
47
+
48
+ The declaration now drives BOTH. `useMutation`'s auto-optimistic stages a patch on every subscription sourced on the junction table, reconciling that anchor's links against `input[field]` — surplus links removed, new ones staged, surviving links left untouched with their real ids (a diff, mirroring `store.relationLinks(...).set`, not a drop-and-restage that would blink every unchanged row).
49
+
50
+ The patches ride the ordinary optimistic lane, staged under the mutation id, so the rollback rule holds by construction: reverted on failure, kept on success until the base actually moves. Nothing here is on a timer.
51
+
52
+ **Migration — `relations:` values are objects now:**
53
+
54
+ ```ts
55
+ // before
56
+ target: { table: 'employees', op: 'update',
57
+ relations: { assignedStores: 'employee_assigned_stores' } }
58
+
59
+ // after
60
+ target: { table: 'employees', op: 'update',
61
+ relations: { assignedStores: {
62
+ junction: 'employee_assigned_stores',
63
+ anchorColumn: 'employeeId', // the junction reference() pointing at `employees`
64
+ targetColumn: 'storeId', // the junction's other reference()
65
+ } } }
66
+ ```
67
+
68
+ The columns are declaration data because the optimistic patch runs in the BROWSER, which has no table registry to derive them from — `@voltro/database` is server-only by construction, and guessing a column from a table name is exactly what `store.relationLinks` refuses to do. They are not taken on trust: before it writes, the server compares the declaration against the junction's real reference columns and refuses, naming the correct pair, if they disagree. A wrong declaration is a loud error carrying its own fix, never a client that patches one column while the server writes another.
69
+
70
+ Semantics unchanged and now shared by both sides: an absent input field touches nothing (absent ≠ empty), an empty array is the explicit clear. The client uses `input.id` for an update and, for an insert, the same optimistic id it stamped on the new row — the server's `output.id` is not knowable before the response.
71
+
72
+ **`voltro update` carries you across this** — codemod `0.55.0/01_target-relations-declare-columns`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.55.0).
73
+ - **@voltro/client, @voltro/ui, @voltro/ui-shadcn, @voltro/web, @voltro/cli** — **A rich-text field, and the sanitizing contract is the point of it.** `RichTextDocument` (`@voltro/client`) is the value; `widget: 'rich-text'` renders it; `<RichTextView>` (`@voltro/ui`, re-exported by `@voltro/web`) displays it.
74
+
75
+ The contract, stated plainly because the alternatives all look reasonable until you name who the attacker is:
76
+
77
+ - **The value is a closed document tree, not an HTML string.** There is no `html` node, no raw-markup escape hatch, no attribute bag. Anything that is not one of the declared node types fails to decode. - **The boundary is the `Schema` decode**, which is the server's existing, non-bypassable input boundary — the same one every mutation input already passes through. So the guarantee is not "somebody remembered to sanitize this"; it is that a document which reached the database is one of these shapes. - **A link's `href` is the one field that points outward, and it is allowlisted** — `http(s)`, `mailto:`, a `#fragment`, a `/path`; nothing else. Control characters and whitespace are stripped before the check, because `java\tscript:` navigates exactly like `javascript:` and a check on the raw string passes it. - **Client-side is not a boundary and is not treated as one.** The widget's parser runs in the browser for the editing experience; every property it maintains is re-established by the decode on the server. - **Rendering never uses `dangerouslySetInnerHTML`.** Nodes become React elements, text becomes React children — so markup typed into the box is markup the reader SEES. `<RichTextView>` also drops an href that would not survive a decode, for the value that never went through one.
78
+
79
+ Rejected, for the record: sanitizing an HTML string on write (ships an HTML parser and the mXSS surface that comes with it), escaping at render (makes safety a property of every read site), and declaring an unenforced boundary (a convention, not a guarantee).
80
+
81
+ The built-in widget is a `<textarea>` over a small, CLOSED markdown subset — headings, `**bold**`, `*italic*`, `` `code` ``, `[text](href)`, lists, blockquote, fenced code — with everything unrecognised left as literal text. That also closes the no-JS loop: the textarea posts source, `/form/*` parses it, and the same decode validates it. A WYSIWYG belongs at rung 2 (register a `rich-text` widget); the stored value shape does not change.
82
+
83
+ **Not** the collaborative case: `crdtDoc()` + `useCrdtEditor` remains the multi-writer path (a CRDT bytes column, a sync lane, Tiptap). This is one column, one writer, ordinary JSON the server can validate and diff, and no new dependency in the default kit.
84
+
85
+ **Migration:** `WidgetKind` gained `'rich-text'`. Only a registry typed as a TOTAL map (`Record<WidgetKind, Widget>`) notices — add one entry pointing at the exported `RichTextWidget`. Partial registries need nothing.
86
+
87
+ Also fixed alongside: the capability manifest's `MANIFEST_WIDGET_KINDS` is a hand copy of `WidgetKind` (a CLI module cannot import `@voltro/client`) and had silently drifted by two kinds since 0.53.0, telling coding agents a smaller set than the renderer accepts. It is complete again and pinned by a test that reads the union out of the client's source.
88
+
89
+ **`voltro update` carries you across this** — codemod `0.55.0/02_widget-kind-gained-rich-text`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.55.0).
90
+
91
+ ### Added
92
+
93
+ - **@voltro/database, @voltro/data-transfer, @voltro/cli, @voltro/voltro** — A staged `--mode replace` now RECORDS the scratch tables it creates, and a boot collects the ones nobody is coming back for.
94
+
95
+ Staging tables are `_voltro_staging_`-prefixed, so the differ correctly ignores them — and nothing else mentioned them either. A run that died between the load and the swap left a full copy of a bundle that only a hand-written introspection could find, on a database whose boot said nothing. The marker row now names the set, carries a heartbeat the run refreshes while rows land, and records whether the run was started `--no-atomic`. That is what lets a boot tell the three cases apart: silent past the threshold and not resumable → the tables are dropped; still beating → an import is loading into them, here or on another replica; resumable → its staging IS the resume point and is left alone however stale. `VOLTRO_STAGING_STALE_MINUTES` moves the threshold (default 30). A staging record is reported, never a refusal — a staged replace destroys nothing until one short server-side swap, so refusing a boot over it would be an alarm on a healthy database. `voltro data clear-staging` reads the same records and labels each table with what its own run says, instead of listing them flat under a warning that it could not tell a leftover from an import in flight.
96
+
97
+ Two smaller things came with it. The raw-SQL seam the staged swap runs on (`DataStore.run`) is a DECLARED optional capability now, in the shape `emptyTables` established, with a parity assertion across the four dialect stores — it was duck-typed against an interface that never mentioned it, so a store that dropped it would have fallen out of the feature detection and taken the slower path forever, on that one dialect, in silence. And a registered staging clone can no longer reach the differ's DECLARED side: `--mode replace` registers each staging table as a clone of its target for the length of the load (a typed write resolves its columns by name), and a plan computed while an import was in flight proposed `create-table _voltro_staging_notes`.
98
+
99
+ Also measured rather than assumed: the staging table `createStagingSql` builds on **sqlite** (`CREATE TABLE … AS SELECT * FROM t WHERE 0`) and on **SQL Server** (`SELECT * INTO … WHERE 1 = 0`) carries the target's columns and ZERO foreign keys, which is what the load needs. Those were the two dialects the postgres and mysql-family measurements had not covered.
100
+ - **@voltro/client, @voltro/ui, @voltro/web** — **`useFormField(path)` finds its binding.** `<FormBindingProvider binding={form}>` (mounted for you by `<AutoForm>`) makes the narrow per-field subscription reachable without threading the binding down to every field component as a prop. That thread was blocking incremental adoption: a codebase moving a hundred-plus forms one at a time keeps its own field context and swaps engines per form, and being asked to prop-drill to ~30 field components at once meant taking the binding and declining the optimisation they had the most to gain from.
101
+
102
+ **Message ids a real catalogue needed**, each because the generic answer is worse at the point of use:
103
+
104
+ - `betweenLength {min,max}` when a field carries BOTH bounds — "at least 2" is a half-truth for a rule that is "between 2 and 50" — and `exactLength {amount}` when they are equal. Read from the schema, not the failing issue: piping nests the later refinement outermost, so the sibling bound is not reachable from the issue that failed. - `invalidEmail` / `invalidUrl` / `invalidUuid` when the refinement declares a JSON-Schema `format`. A bare regex cannot name its own rule, and "Invalid format" beside an email box tells nobody anything. - `minDate` / `maxDate`, because a date bound rendered as a number bound reads "must be at least 2026-01-01". - `invalidFileType` / `fileTooLarge` — not produced by any refinement, carried so an app's own `ctx.validation.fail('doc', 'validation.fileTooLarge')` renders a sentence rather than an id.
105
+
106
+ `apiSurface: compatible` — `useFormField` goes from a const arrow to an overloaded function so it can take `(path)` as well as `(binding, path)`. The golden line for the old signature is replaced rather than removed: every existing `useFormField(form, path)` call compiles unchanged, because that overload is still declared first-class. Only code capturing the function's exact TYPE (rather than calling it) sees a difference.
107
+
108
+ **Counting rules pass `count`.** `minItems` / `maxItems` carry `{ count }` beside `{min}`/`{max}`: i18next selects a plural form on a parameter named exactly `count`, so ids passing only `{min}` could not be pluralised at all.
109
+ - **@voltro/runtime, @voltro/cli** — **The resume census — `/_voltro/inspect/subscriptions` now carries `resume`.** Per query label: how many subscriptions recorded a delta-resume ring, and how many were excluded, counted per reason (`computed`, `row-filter`, `eager-load`, `uncanonical-input`, `not-offered`). `voltro dev` also logs each verdict once per label under the `voltro:resume` scope — debug is the default level outside production, so it is already on where the tuning happens and off where it is served.
110
+
111
+ It exists because the two failure shapes are indistinguishable from outside. A subscription excluded by a row filter and one whose executor returns a **value** rather than a descriptor both reconnect with a fresh snapshot and rows on the screen, so an app measuring its own reconnects cannot tell which of its queries a `tables:` declaration is even capable of helping. The answer is which of the two bind paths the executor took, and nothing on the wire carries it.
112
+
113
+ The reasons are the load-bearing part, not the counts: `computed` means no declaration can ever change this query, `row-filter` means the filter narrows its source and the exclusion is the point, and `eager-load` is reported ONLY when the base table is not itself narrowed — so that verdict always means "drop the `.with(...)` and this one resumes". Counts rather than one verdict per label, because resumability is not purely a property of the label: an input that does not canonicalise is a property of the value, so one query can be resumable for one subscriber and excluded for the next. A label nobody has subscribed to is ABSENT rather than reported as zero — "nothing has subscribed yet" and "every query is excluded" must not read the same.
114
+
115
+ **And a correction to what `tables:` was documented to buy.** The 0.54.0 notes, the `tables:` doc comment and the row-level-security page all said one registration cost delta-resume on every query descriptor in an app, with a count beside it. The count was real; the sentence around it claimed those descriptors would have HAD the feature, and that was never measured. A query only has a delta chain when its executor returns a descriptor — one that maps its rows or wraps them in a page envelope re-runs an opaque handler and emits snapshots, filter or no filter. So the number a declaration gives back is the number of descriptor-returning subscriptions, not the number of queries. The docs now say that where the decision is made, and the census is how you find out which shape each of yours took.
116
+
117
+ ### Changed
118
+
119
+ - `dataTransfer.stagingStaleMinutes` in `app.config.ts` — how long a staged import's silence has to last before a boot treats its scratch tables as abandoned. Previously `VOLTRO_STAGING_STALE_MINUTES` only; the env var still overrides the declaration, on the rule every other tunable here follows.
120
+
121
+ The reason this was not already a field was recorded as "the boot check runs off the store alone, before the app config is threaded to it". That described the function's signature, not the boot: both paths already held the config three lines above the call. Resolution lives inside `stagingLeftoversAtBoot` rather than at either call site, so the two cannot disagree about what a declared value means, and a source-reading assertion fails if either path stops handing the config over.
122
+
123
+ ### Fixed
124
+
125
+ - **@voltro/protocol, @voltro/cli, @voltro/client, @voltro/plugin-notifications, @voltro/plugin-comments, @voltro/plugin-presence, @voltro/plugin-search, @voltro/plugin-flags** — **Installing a plugin under an `alias` now moves its client too.** `alias` exists for one problem — your app already publishes `notifications.*` and cannot install a plugin that wants the same namespace — and it has to move four surfaces or it is worse than not existing. It moved three.
126
+
127
+ The two that did not:
128
+
129
+ - **The generated client sent the tag the PLUGIN authored.** Every lifter is `Rpc.make(descriptor.name, …)`, so the wire tag comes from the descriptor, not from the tag the codegen computed. Under `alias: 'inbox'` the exported identifier became `inboxInboxRpc`, the `appDescriptors` key became `inbox.inbox`, the type key became `inbox.inbox` — and the browser still asked a server that had stopped serving it for `notifications.inbox`. The codegen now lifts every plugin descriptor through `withRpcTag(…)`, unconditionally, so the aliased and un-aliased cases are one code path rather than a branch nothing exercises. - **The plugin's own hooks spelled their namespace as a literal.** `useInbox()`, `useUpload()`, `useComments()`, `usePresence()`, `useFlag()`, `useSearch()` all carried strings like `'notifications.inbox'`, which no alias could reach. `voltro dev` now writes a `registerPluginAliases({ … })` declaration into `rpcGroup.generated.ts` — the module the web client already loads value-level — and every plugin hook resolves its tag through `pluginTag(baseName, route)` from `@voltro/protocol` at call time, not at module load. Aliasing a plugin needs no change at any call site.
130
+
131
+ Two installs of one plugin (`name: 'ops'`) with no un-suffixed primary make `pluginTag` **refuse** rather than pick: a hook has no way to name an install, and guessing would address the wrong one silently. The error names both candidates and points at the full-tag call that says which you mean.
132
+
133
+ `pluginAlias` / `pluginSlug` moved from the CLI into `@voltro/protocol` (and are re-exported from their old path) because the browser has to derive the same namespace the server registered, and a second copy on the client would be a second definition of the rule with nothing comparing them.
134
+
135
+ Also corrected, in the same seam: the list of plugins that deliberately do NOT accept `tables: false` read as exhaustive and omitted `_voltro_storage_grants`, which decides who may read an object. It is named now — along with the reason the option would not have reached it anyway (storage's tables are framework tables, not `extendSchema` contributions).
136
+ - `voltro build` could not produce an api serve bundle on 0.53.0 or 0.54.0.
137
+
138
+ `@voltro/content`'s `get.ts` reaches its render pipeline through a dynamic `import('./serverLoad')`. That is deliberate — an unresolvable-at-build-time specifier is what keeps marked and the shiki grammars out of a consumer's client chunk graph. But `serverLoad` was not in the package's entry map, so nothing emitted `dist/serverLoad.js`, and `dist/index.js` shipped an import of a file beside it that was not there.
139
+
140
+ It resolved in this repo every time, because the workspace `exports` point at `src/` and `serverLoad.ts` sits next to `get.ts`. A consumer resolves `publishConfig.exports` to `dist/index.js`, and the same line cannot resolve. esbuild does not honour `@vite-ignore` — that is a vite directive — so the serve bundle refused to ship. The refusal was right; nothing had ever triggered it.
141
+
142
+ Two things worth knowing, both measured rather than reasoned:
143
+
144
+ - **The build was stopped by a dependency that contributes nothing to it.** An api serve bundle reaches `@voltro/content` through `serveCommand → dev → webDev → contentWiring`, and esbuild resolves before it tree-shakes. After shaking, the content pipeline is **0 bytes** of a 14.42 MB bundle. So an api app with no markdown anywhere was blocked by a markdown loader whose code it would never have carried. - **Shipping the file does not bloat anything.** Same measurement with the fixed package resolved as a consumer resolves it: 14.42 MB, content still 0 bytes. The dynamic import stays shaken away.
145
+
146
+ `scripts/check-dist-internal-specifiers.mjs` now bundles every emitted file of every publishable package — from `.publish/`, the tree users receive — with bare specifiers external, and fails if any relative specifier does not resolve. GATE-2 (`publint`) answers "does a declared subpath resolve"; this is one level below it, where `./serverLoad` lives.
147
+ - **@voltro/client** — Three places still taught the pre-fix contract for a cold-start failure.
148
+
149
+ `SubscriptionFailed` gives it its own state — `loading: false`, `failed: true`, `error` non-optional — precisely so a component branching on `loading` alone cannot render a skeleton forever. But `SubscriptionMeta.error`'s doc comment and two docs pages still said the opposite ("leaves `loading` TRUE … check `error` to break out of it"), which is the sentence a deployment quoted back at us as evidence for the defect that had already been fixed.
150
+
151
+ A comment that predicts a trap the code no longer has is worse than no comment: it teaches the defensive shape as if it were still required, and it invites the reading that `loading` is unreliable. All four now describe the state that exists, with the old behaviour kept only as the history that explains why the field is there.
152
+ - **@voltro/client, @voltro/web, @voltro/cli, @voltro/ui** — Four defects a real migration found, all of which passed `tsc` and a full test suite and only showed up against a running system.
153
+
154
+ **A server render derived a different form than the browser.** Nothing mounts a runtimes provider during SSR, so `useFormBinding` resolved its input schema from an EMPTY descriptor map: no fields, `required: false`. The browser then rendered the real ones and React discarded the subtree — "Hydration failed" on every server-rendered page carrying a bound form, with the diff pointing at a `Mui-required` class. `@voltro/client` keeps a process-global SSR descriptor registry now, and both boot paths fill it before rendering (`voltro dev` and `voltro start`, pinned as a parity test — a CLI module cannot import `@voltro/client`, so the call goes through `@voltro/web/ssr`, which both already load).
155
+
156
+ **A rejected `submit()` had nowhere to go.** A form calls it from an `onSubmit` handler that cannot await it, so the rejection surfaced as `Uncaught (in promise)` while the form sat there looking saved. `submit()` resolves `undefined` now and the failure is state: `state.submitError`, plus an optional `onError`. That covers a composed `onSubmit` whose follow-up write fails on its OWN mutation handle — a failure the binding never saw, and the common shape (create the row, then its first child).
157
+
158
+ **Undeclared fields went on the wire.** The binding validated the mapped input and then sent the object unchanged; the client decode ignores excess properties while the server has refused them since 0.37, so a form carrying anything beyond the mutation's input passed validation and was rejected on the wire with a message pointing at no field. The payload is restricted to the declared keys now — the rule the no-JS path already followed, so the two submits agree — with a dev warning naming what was dropped.
159
+
160
+ **`setValue` with an unchanged value produced a new `values`.** Every React state source is expected to no-op on that; this one did not, so an effect depending on `values` that re-set a field to the value it already held never settled ("Maximum update depth exceeded" on a form mirroring toggles out of a multi-select).
161
+ - A plugin's dashboard panel no longer disappears when the app aliases the plugin.
162
+
163
+ `alias` moves `plugin.name`, and the inspect mount is derived from it, so `alias: 'inbox'` on notifications moved its panel to `/_voltro/inspect/plugins/inbox/...` while both dashboards ask for `/plugins/notifications/...` with the path compiled in. They live in other repositories and cannot follow. The field's own doc comment stated this as a cost you accept — in nine plugins, the protocol helper and the docs.
164
+
165
+ `makePluginInspectRegistry` now mounts each plugin's `inspectEndpoints` under its CANONICAL slug as well, in a second pass so an effective mount always wins the path. Added only where unambiguous: a base name carried by more than one installed plugin gets no shared mount, because showing either install under it would hand a dashboard the other one's rows under a name that looks right — the hazard `pluginTag` refuses rather than guesses. `/_voltro/inspect/plugins` now reports `baseName` and `inspectSlug` per plugin, which is how a caller reaches a specific install.
166
+ - **@voltro/cli, @voltro/data-transfer** — **`voltro data restore --drill` failed every healthy backup of a real app.** It compared the restored schema's fingerprint against the backup stamp's `schemaFingerprint`, which records the SOURCE database's whole live schema — and the artifact never carries that schema. `pg_dump` / `mariadb-dump` exclude `_voltro_replace_in_progress` and `_voltro_data_transfers` on purpose, and the backup command opens a run row in the second one before it dumps, so on any database the framework has run against, the artifact is two tables short of the value it was being measured against. The drill answered:
167
+
168
+ FAIL — restored N table(s), but the schema fingerprint (…) does NOT match the backup's stamp (…). The restore did not reproduce the schema that was backed up — the artifact is inconsistent.
169
+
170
+ about an artifact that was exactly right. A drill exists to be wired to a CI cron, and one that is red on every healthy input gets switched off — taking its two real failures with it.
171
+
172
+ The stamp now carries a second value, `dumpFingerprint`: the same snapshot minus `dumpExcludedTables(dialect)` — what a faithful restore must reproduce. The drill compares against that. A stamp written before this field degrades to a PARTIAL pass that says so, rather than falling back to the value that produces the false failure. `dumpExcludedTables` is per-dialect because only two of the five backup paths carry an exclusion flag at all: the sqlite/turso copy and the mssql export carry everything, and subtracting a set from those would invent the same bug in the other direction.
173
+
174
+ **The drill also checks the one boot-fatal condition a schema comparison cannot see.** `voltro serve`'s boot gate reads the newest `_voltro_migration_plans` row and refuses with `prod-mismatch` when there is none — so a ledger table that restores with exactly the right columns and zero rows is a database no source tree can boot, and its fingerprint is identical to a healthy one's. That is now a FAIL with the reason named. A restored database with no ledger table at all is not a voltro-managed schema and is reported as such, not failed.
175
+
176
+ There is deliberately no app boot in the drill. The boot gate is a comparison, not a startup sequence, so the part that generalises is reachable with a SELECT; booting a fixture app instead would prove something about our fixture rather than about your backup.
177
+
178
+ ---
179
+
42
180
  ## [0.54.0] — 2026-08-27
43
181
 
44
182
  ### ⚠ BREAKING
@@ -114,9 +252,11 @@ _Changes staged for the next release accumulate here (rolled up from
114
252
  Each message is processed inside a `queue.consume` span whose parent is the incoming `traceparent`, parsed by the same `externalSpanFromTraceparent` the inbound-webhook path uses — so a malformed or all-zero header means "no parent" (a fresh root span), never a failed message. The span covers the whole message including retries and the dead-letter publish, and carries the OTel `messaging.*` attributes plus `voltro.queue.outcome`. The topic is an attribute, not part of the span name.
115
253
 
116
254
  Known limit, measured rather than assumed: a span opened from DETACHED work does not reach an OTLP exporter, because the framework's tracer is a `Layer` provided only inside the rpc server's scope. This is framework-wide (`cdcOut.deliver` and `plugin.<name>.schedule-fire` are the same shape) and is stated in the queue docs rather than implied away. The metrics are unaffected — the metric registry is a process global.
117
- - **@voltro/runtime, @voltro/voltro** — `setRowFilter({ …, tables: ['bookmarks', 'recentSearches'] })` — declare which tables your filter may narrow, and delta-resume survives everywhere else.
255
+ - **@voltro/runtime, @voltro/voltro** — `setRowFilter({ …, tables: ['documents', 'comments'] })` — declare which tables your filter may narrow, and delta-resume survives everywhere else.
256
+
257
+ Delta-resume is excluded for a subscription whose row set is re-resolved per delivery: replaying deltas could serve rows the subject has since lost. But the question the runtime could ask was only "is a filter registered in this process?", so ONE registration disabled cheap reconnects for every subscription in the app, including every one whose source the filter could never narrow.
118
258
 
119
- Delta-resume is excluded for a subscription whose row set is re-resolved per delivery: replaying deltas could serve rows the subject has since lost. But the question the runtime could ask was only "is a filter registered in this process?", so ONE registration disabled cheap reconnects for every subscription in the app a deployment measured a filter narrowing 4 tables costing the feature on all 173 of their query descriptors, 55 of whose source tables the filter never touches.
259
+ It does not reach every query. A subscription only has a delta chain when its executor returns a DESCRIPTOR; one that returns a mapped value or a page envelope re-runs an opaque handler and emits snapshots, with or without a filter. `tables:` costs nothing and applies the moment such a query returns a builder but the count it gives back is the count of descriptor-returning subscriptions, not of queries. The resume census on `/_voltro/inspect/subscriptions` reports which shape each of your queries took.
120
260
 
121
261
  The exclusion is per table now. A declared filter keeps resume for every subscription whose source is not in its set; an undeclared filter keeps today's conservative behaviour (the runtime cannot know which tables the predicate may reach, and "unknown" must read as "yes").
122
262
 
package/dist/index.js CHANGED
@@ -1,52 +1,52 @@
1
1
  import { CommentAccessRefused as e, CommentNotFound as t, CommentNotYours as n } from "./errors.js";
2
- import { commentsFeed as r, commentsRpcClientImports as i, createDescriptor as a, deleteDescriptor as o, editDescriptor as s, listDescriptor as c, markReadDescriptor as l, mentionSearchDescriptor as u, reactDescriptor as d, resolveDescriptor as f } from "./rpc.js";
3
- import { Effect as p, Option as m } from "effect";
4
- import { and as h, eq as g, generateId as _, id as v, json as y, table as b, text as x, timestamp as S } from "@voltro/database";
5
- import { definePlugin as C, pluginInstanceName as w, publishReactivity as T } from "@voltro/protocol";
6
- import { NotificationService as E } from "@voltro/plugin-notifications";
2
+ import { BASE_NAME as r, commentsFeed as i, commentsRpcClientImports as a, createDescriptor as o, deleteDescriptor as s, editDescriptor as c, listDescriptor as l, markReadDescriptor as u, mentionSearchDescriptor as d, reactDescriptor as f, resolveDescriptor as p } from "./rpc.js";
3
+ import { Effect as m, Option as h } from "effect";
4
+ import { and as g, eq as _, generateId as v, id as y, json as b, table as x, text as S, timestamp as C } from "@voltro/database";
5
+ import { definePlugin as w, pluginInstanceName as T, publishReactivity as E } from "@voltro/protocol";
6
+ import { NotificationService as D } from "@voltro/plugin-notifications";
7
7
  //#region src/index.ts
8
- var D = b("_voltro_comment_threads", {
9
- id: v({ prefix: "cmtthr" }),
10
- anchor: x(),
11
- status: x().default("open"),
12
- createdBy: x(),
13
- createdAt: S().default("now"),
14
- tenantId: x().nullable()
15
- }).index("byThreadAnchor", ["anchor", "tenantId"]), O = b("_voltro_comments", {
16
- id: v({ prefix: "cmt" }),
17
- threadId: x(),
18
- body: x(),
19
- authorSubjectId: x(),
20
- mentions: y(),
21
- editedAt: S().nullable(),
22
- createdAt: S().default("now"),
23
- tenantId: x().nullable()
24
- }).index("byCommentThread", ["threadId"]), k = b("_voltro_comment_reactions", {
25
- id: v({ prefix: "cmtrx" }),
26
- commentId: x(),
27
- subjectId: x(),
28
- emoji: x()
8
+ var O = x("_voltro_comment_threads", {
9
+ id: y({ prefix: "cmtthr" }),
10
+ anchor: S(),
11
+ status: S().default("open"),
12
+ createdBy: S(),
13
+ createdAt: C().default("now"),
14
+ tenantId: S().nullable()
15
+ }).index("byThreadAnchor", ["anchor", "tenantId"]), k = x("_voltro_comments", {
16
+ id: y({ prefix: "cmt" }),
17
+ threadId: S(),
18
+ body: S(),
19
+ authorSubjectId: S(),
20
+ mentions: b(),
21
+ editedAt: C().nullable(),
22
+ createdAt: C().default("now"),
23
+ tenantId: S().nullable()
24
+ }).index("byCommentThread", ["threadId"]), A = x("_voltro_comment_reactions", {
25
+ id: y({ prefix: "cmtrx" }),
26
+ commentId: S(),
27
+ subjectId: S(),
28
+ emoji: S()
29
29
  }).unique("byReactionIdentity", [
30
30
  "commentId",
31
31
  "subjectId",
32
32
  "emoji"
33
- ]).index("byReactionComment", ["commentId"]), A = b("_voltro_comment_reads", {
34
- id: v({ prefix: "cmtread" }),
35
- threadId: x(),
36
- subjectId: x(),
37
- lastReadAt: S()
38
- }).unique("byReadThreadSubject", ["threadId", "subjectId"]), j = "@voltro/plugin-comments", M = (e) => {
33
+ ]).index("byReactionComment", ["commentId"]), j = x("_voltro_comment_reads", {
34
+ id: y({ prefix: "cmtread" }),
35
+ threadId: S(),
36
+ subjectId: S(),
37
+ lastReadAt: C()
38
+ }).unique("byReadThreadSubject", ["threadId", "subjectId"]), M = r, N = (e) => {
39
39
  let t = e.request.subject ?? {};
40
40
  return {
41
41
  id: t.id ?? null,
42
42
  tenantId: t.tenantId ?? null,
43
43
  scopes: t.scopes ?? []
44
44
  };
45
- }, N = (v = {}) => {
46
- let y = w({
47
- base: j,
48
- alias: v.alias,
49
- instance: v.name
45
+ }, P = (r = {}) => {
46
+ let y = T({
47
+ base: M,
48
+ alias: r.alias,
49
+ instance: r.name
50
50
  }), b, x, S = async (e, t, n) => b === void 0 ? [] : b.query({
51
51
  table: e,
52
52
  predicate: t,
@@ -54,40 +54,40 @@ var D = b("_voltro_comment_threads", {
54
54
  take: n,
55
55
  skip: void 0,
56
56
  projection: void 0
57
- }), N = () => {
58
- T(x, r);
59
- }, P = (t, n) => p.gen(function* () {
60
- let r = M(n);
61
- return v.access?.viaEntity === void 0 ? v.access?.scope === void 0 ? yield* p.fail(new e({
57
+ }), C = () => {
58
+ E(x, i);
59
+ }, P = (t, n) => m.gen(function* () {
60
+ let i = N(n);
61
+ return r.access?.viaEntity === void 0 ? r.access?.scope === void 0 ? yield* m.fail(new e({
62
62
  anchor: t,
63
63
  reason: "no access rule declared — set commentsPlugin({ access: { viaEntity } }) or { access: { scope } }"
64
- })) : r.scopes.includes(v.access.scope) ? void 0 : yield* p.fail(new e({
64
+ })) : i.scopes.includes(r.access.scope) ? void 0 : yield* m.fail(new e({
65
65
  anchor: t,
66
- reason: `missing scope '${v.access.scope}'`
67
- })) : (yield* p.promise(async () => {
66
+ reason: `missing scope '${r.access.scope}'`
67
+ })) : (yield* m.promise(async () => {
68
68
  try {
69
- return await v.access.viaEntity({
69
+ return await r.access.viaEntity({
70
70
  anchor: t,
71
- subject: r,
71
+ subject: i,
72
72
  store: b
73
73
  });
74
74
  } catch {
75
75
  return !1;
76
76
  }
77
- })) ? void 0 : yield* p.fail(new e({
77
+ })) ? void 0 : yield* m.fail(new e({
78
78
  anchor: t,
79
79
  reason: "the anchored entity is not readable by this caller (or no longer available)"
80
80
  }));
81
- }), F = async (e) => (await S("_voltro_comment_threads", g("id", e), 1))[0], I = (e) => e instanceof Date ? e.toISOString() : typeof e == "string" ? e : "", L = (e) => p.gen(function* () {
81
+ }), F = async (e) => (await S("_voltro_comment_threads", _("id", e), 1))[0], I = (e) => e instanceof Date ? e.toISOString() : typeof e == "string" ? e : "", L = (e) => m.gen(function* () {
82
82
  if (e.mentions.length === 0) return;
83
- let t = yield* p.serviceOption(E);
84
- if (m.isNone(t)) {
85
- yield* p.logInfo("comments: mention recorded without notification — @voltro/plugin-notifications is not configured");
83
+ let t = yield* m.serviceOption(D);
84
+ if (h.isNone(t)) {
85
+ yield* m.logInfo("comments: mention recorded without notification — @voltro/plugin-notifications is not configured");
86
86
  return;
87
87
  }
88
- for (let n of e.mentions) n !== e.author && (yield* p.promise(() => t.value.send({
88
+ for (let n of e.mentions) n !== e.author && (yield* m.promise(() => t.value.send({
89
89
  to: n,
90
- category: v.mentionCategory ?? "mention",
90
+ category: r.mentionCategory ?? "mention",
91
91
  title: "You were mentioned",
92
92
  body: e.body.length > 200 ? `${e.body.slice(0, 200)}…` : e.body,
93
93
  data: {
@@ -97,24 +97,24 @@ var D = b("_voltro_comment_threads", {
97
97
  tenantId: e.tenantId
98
98
  }).then(() => void 0).catch(() => void 0)));
99
99
  }), R = async (e, t) => {
100
- if (e.length === 0 || v.resolveMentions === void 0) return [];
100
+ if (e.length === 0 || r.resolveMentions === void 0) return [];
101
101
  let n = [];
102
- for (let r of [...new Set(e)].slice(0, 20)) {
103
- let e = (await v.resolveMentions({
104
- query: r,
102
+ for (let i of [...new Set(e)].slice(0, 20)) {
103
+ let e = (await r.resolveMentions({
104
+ query: i,
105
105
  subject: t
106
- }).catch(() => [])).find((e) => e.subjectId === r);
107
- e !== void 0 && (v.crossTenant === !0 || e.tenantId === void 0 || e.tenantId === null || e.tenantId === t.tenantId) && n.push(r);
106
+ }).catch(() => [])).find((e) => e.subjectId === i);
107
+ e !== void 0 && (r.crossTenant === !0 || e.tenantId === void 0 || e.tenantId === null || e.tenantId === t.tenantId) && n.push(i);
108
108
  }
109
109
  return n;
110
110
  }, z = async (e, t, n) => {
111
- let r = await S("_voltro_comment_threads", h(g("anchor", e), g("tenantId", t))), i = [];
111
+ let r = await S("_voltro_comment_threads", g(_("anchor", e), _("tenantId", t))), i = [];
112
112
  for (let e of r) {
113
- let t = [...await S("_voltro_comments", g("threadId", String(e.id)))];
113
+ let t = [...await S("_voltro_comments", _("threadId", String(e.id)))];
114
114
  t.sort((e, t) => I(e.createdAt) < I(t.createdAt) ? -1 : 1);
115
- let r = n === null ? [] : await S("_voltro_comment_reads", h(g("threadId", String(e.id)), g("subjectId", n)), 1), a = r[0] ? I(r[0].lastReadAt) : "", o = [];
115
+ let r = n === null ? [] : await S("_voltro_comment_reads", g(_("threadId", String(e.id)), _("subjectId", n)), 1), a = r[0] ? I(r[0].lastReadAt) : "", o = [];
116
116
  for (let e of t) {
117
- let t = await S("_voltro_comment_reactions", g("commentId", String(e.id))), r = /* @__PURE__ */ new Map();
117
+ let t = await S("_voltro_comment_reactions", _("commentId", String(e.id))), r = /* @__PURE__ */ new Map();
118
118
  for (let e of t) {
119
119
  let t = r.get(String(e.emoji)) ?? {
120
120
  count: 0,
@@ -150,31 +150,31 @@ var D = b("_voltro_comment_threads", {
150
150
  return i.sort((e, t) => String(e.createdAt) < String(t.createdAt) ? -1 : 1), i;
151
151
  }, B = "comments:moderate", V = [
152
152
  {
153
- ...c,
153
+ ...l,
154
154
  description: "Threads + comments + reactions + unread for one anchor, live.",
155
- execute: (e, t) => p.gen(function* () {
155
+ execute: (e, t) => m.gen(function* () {
156
156
  let { anchor: n } = e;
157
157
  yield* P(n, t);
158
- let r = M(t);
159
- return yield* p.promise(() => z(n, r.tenantId, r.id));
158
+ let r = N(t);
159
+ return yield* m.promise(() => z(n, r.tenantId, r.id));
160
160
  })
161
161
  },
162
162
  {
163
- ...a,
163
+ ...o,
164
164
  description: "Comment on an anchor — a reply into a thread, or a new thread.",
165
- execute: (e, n) => p.gen(function* () {
165
+ execute: (e, n) => m.gen(function* () {
166
166
  let r = e;
167
167
  yield* P(r.anchor, n);
168
- let i = M(n), a = i.id ?? "anonymous", o = b;
169
- if (o === void 0) return yield* p.fail(new t({ id: r.anchor }));
168
+ let i = N(n), a = i.id ?? "anonymous", o = b;
169
+ if (o === void 0) return yield* m.fail(new t({ id: r.anchor }));
170
170
  let s = r.threadId;
171
171
  if (s !== void 0) {
172
- let e = yield* p.promise(() => F(s));
173
- if (e === void 0 || String(e.anchor) !== r.anchor) return yield* p.fail(new t({ id: s }));
174
- } else s = _({
172
+ let e = yield* m.promise(() => F(s));
173
+ if (e === void 0 || String(e.anchor) !== r.anchor) return yield* m.fail(new t({ id: s }));
174
+ } else s = v({
175
175
  kind: "typeid",
176
176
  prefix: "cmtthr"
177
- }, "_voltro_comment_threads"), yield* p.promise(() => o.insert("_voltro_comment_threads", {
177
+ }, "_voltro_comment_threads"), yield* m.promise(() => o.insert("_voltro_comment_threads", {
178
178
  id: s,
179
179
  anchor: r.anchor,
180
180
  status: "open",
@@ -182,11 +182,11 @@ var D = b("_voltro_comment_threads", {
182
182
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
183
183
  tenantId: i.tenantId
184
184
  }));
185
- let c = yield* p.promise(() => R(r.mentions ?? [], i)), l = _({
185
+ let c = yield* m.promise(() => R(r.mentions ?? [], i)), l = v({
186
186
  kind: "typeid",
187
187
  prefix: "cmt"
188
188
  }, "_voltro_comments");
189
- return yield* p.promise(() => o.insert("_voltro_comments", {
189
+ return yield* m.promise(() => o.insert("_voltro_comments", {
190
190
  id: l,
191
191
  threadId: s,
192
192
  body: r.body,
@@ -195,7 +195,7 @@ var D = b("_voltro_comment_threads", {
195
195
  editedAt: null,
196
196
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
197
197
  tenantId: i.tenantId
198
- })), N(), yield* L({
198
+ })), C(), yield* L({
199
199
  mentions: c,
200
200
  author: a,
201
201
  anchor: r.anchor,
@@ -209,76 +209,76 @@ var D = b("_voltro_comment_threads", {
209
209
  })
210
210
  },
211
211
  {
212
- ...s,
212
+ ...c,
213
213
  description: "Edit your own comment.",
214
- execute: (e, r) => p.gen(function* () {
215
- let i = e, a = (yield* p.promise(() => S("_voltro_comments", g("id", i.commentId), 1)))[0];
216
- if (a === void 0) return yield* p.fail(new t({ id: i.commentId }));
217
- let o = yield* p.promise(() => F(String(a.threadId)));
218
- return o === void 0 ? yield* p.fail(new t({ id: i.commentId })) : (yield* P(String(o.anchor), r), String(a.authorSubjectId) === (M(r).id ?? "anonymous") ? (yield* p.promise(() => b.update("_voltro_comments", i.commentId, {
214
+ execute: (e, r) => m.gen(function* () {
215
+ let i = e, a = (yield* m.promise(() => S("_voltro_comments", _("id", i.commentId), 1)))[0];
216
+ if (a === void 0) return yield* m.fail(new t({ id: i.commentId }));
217
+ let o = yield* m.promise(() => F(String(a.threadId)));
218
+ return o === void 0 ? yield* m.fail(new t({ id: i.commentId })) : (yield* P(String(o.anchor), r), String(a.authorSubjectId) === (N(r).id ?? "anonymous") ? (yield* m.promise(() => b.update("_voltro_comments", i.commentId, {
219
219
  body: i.body,
220
220
  editedAt: (/* @__PURE__ */ new Date()).toISOString()
221
- })), N(), { ok: !0 }) : yield* p.fail(new n({ id: i.commentId })));
221
+ })), C(), { ok: !0 }) : yield* m.fail(new n({ id: i.commentId })));
222
222
  })
223
223
  },
224
224
  {
225
- ...f,
225
+ ...p,
226
226
  description: "Resolve or reopen a thread.",
227
- execute: (e, n) => p.gen(function* () {
228
- let r = e, i = yield* p.promise(() => F(r.threadId));
229
- return i === void 0 ? yield* p.fail(new t({ id: r.threadId })) : (yield* P(String(i.anchor), n), yield* p.promise(() => b.update("_voltro_comment_threads", r.threadId, { status: r.resolved ? "resolved" : "open" })), N(), { ok: !0 });
227
+ execute: (e, n) => m.gen(function* () {
228
+ let r = e, i = yield* m.promise(() => F(r.threadId));
229
+ return i === void 0 ? yield* m.fail(new t({ id: r.threadId })) : (yield* P(String(i.anchor), n), yield* m.promise(() => b.update("_voltro_comment_threads", r.threadId, { status: r.resolved ? "resolved" : "open" })), C(), { ok: !0 });
230
230
  })
231
231
  },
232
232
  {
233
- ...o,
233
+ ...s,
234
234
  description: "Delete your own comment (comments:moderate deletes any).",
235
- execute: (e, r) => p.gen(function* () {
236
- let i = e, a = (yield* p.promise(() => S("_voltro_comments", g("id", i.commentId), 1)))[0];
237
- if (a === void 0) return yield* p.fail(new t({ id: i.commentId }));
238
- let o = yield* p.promise(() => F(String(a.threadId)));
239
- if (o === void 0) return yield* p.fail(new t({ id: i.commentId }));
235
+ execute: (e, r) => m.gen(function* () {
236
+ let i = e, a = (yield* m.promise(() => S("_voltro_comments", _("id", i.commentId), 1)))[0];
237
+ if (a === void 0) return yield* m.fail(new t({ id: i.commentId }));
238
+ let o = yield* m.promise(() => F(String(a.threadId)));
239
+ if (o === void 0) return yield* m.fail(new t({ id: i.commentId }));
240
240
  yield* P(String(o.anchor), r);
241
- let s = M(r);
242
- return String(a.authorSubjectId) !== (s.id ?? "anonymous") && !s.scopes.includes(B) ? yield* p.fail(new n({ id: i.commentId })) : (yield* p.promise(async () => {
243
- for (let e of await S("_voltro_comment_reactions", g("commentId", i.commentId))) await b.delete("_voltro_comment_reactions", String(e.id));
241
+ let s = N(r);
242
+ return String(a.authorSubjectId) !== (s.id ?? "anonymous") && !s.scopes.includes(B) ? yield* m.fail(new n({ id: i.commentId })) : (yield* m.promise(async () => {
243
+ for (let e of await S("_voltro_comment_reactions", _("commentId", i.commentId))) await b.delete("_voltro_comment_reactions", String(e.id));
244
244
  await b.delete("_voltro_comments", i.commentId);
245
- }), N(), { ok: !0 });
245
+ }), C(), { ok: !0 });
246
246
  })
247
247
  },
248
248
  {
249
- ...d,
249
+ ...f,
250
250
  description: "Toggle an emoji reaction on a comment.",
251
- execute: (e, n) => p.gen(function* () {
252
- let r = e, i = (yield* p.promise(() => S("_voltro_comments", g("id", r.commentId), 1)))[0];
253
- if (i === void 0) return yield* p.fail(new t({ id: r.commentId }));
254
- let a = yield* p.promise(() => F(String(i.threadId)));
255
- if (a === void 0) return yield* p.fail(new t({ id: r.commentId }));
251
+ execute: (e, n) => m.gen(function* () {
252
+ let r = e, i = (yield* m.promise(() => S("_voltro_comments", _("id", r.commentId), 1)))[0];
253
+ if (i === void 0) return yield* m.fail(new t({ id: r.commentId }));
254
+ let a = yield* m.promise(() => F(String(i.threadId)));
255
+ if (a === void 0) return yield* m.fail(new t({ id: r.commentId }));
256
256
  yield* P(String(a.anchor), n);
257
- let o = M(n).id ?? "anonymous", s = (yield* p.promise(() => S("_voltro_comment_reactions", h(g("commentId", r.commentId), h(g("subjectId", o), g("emoji", r.emoji))), 1)))[0];
258
- if (s !== void 0) return yield* p.promise(() => b.delete("_voltro_comment_reactions", String(s.id))), N(), { reacted: !1 };
259
- let c = _({
257
+ let o = N(n).id ?? "anonymous", s = (yield* m.promise(() => S("_voltro_comment_reactions", g(_("commentId", r.commentId), g(_("subjectId", o), _("emoji", r.emoji))), 1)))[0];
258
+ if (s !== void 0) return yield* m.promise(() => b.delete("_voltro_comment_reactions", String(s.id))), C(), { reacted: !1 };
259
+ let c = v({
260
260
  kind: "typeid",
261
261
  prefix: "cmtrx"
262
262
  }, "_voltro_comment_reactions");
263
- return yield* p.promise(() => b.insert("_voltro_comment_reactions", {
263
+ return yield* m.promise(() => b.insert("_voltro_comment_reactions", {
264
264
  id: c,
265
265
  commentId: r.commentId,
266
266
  subjectId: o,
267
267
  emoji: r.emoji
268
- })), N(), { reacted: !0 };
268
+ })), C(), { reacted: !0 };
269
269
  })
270
270
  },
271
271
  {
272
- ...l,
272
+ ...u,
273
273
  description: "Stamp the caller's read marker for a thread.",
274
- execute: (e, n) => p.gen(function* () {
274
+ execute: (e, n) => m.gen(function* () {
275
275
  let r = e;
276
- if ((yield* p.promise(() => F(r.threadId))) === void 0) return yield* p.fail(new t({ id: r.threadId }));
277
- let i = M(n).id ?? "anonymous", a = _({
276
+ if ((yield* m.promise(() => F(r.threadId))) === void 0) return yield* m.fail(new t({ id: r.threadId }));
277
+ let i = N(n).id ?? "anonymous", a = v({
278
278
  kind: "typeid",
279
279
  prefix: "cmtread"
280
280
  }, "_voltro_comment_reads");
281
- return yield* p.promise(() => b.upsert("_voltro_comment_reads", {
281
+ return yield* m.promise(() => b.upsert("_voltro_comment_reads", {
282
282
  id: a,
283
283
  threadId: r.threadId,
284
284
  subjectId: i,
@@ -290,19 +290,19 @@ var D = b("_voltro_comment_threads", {
290
290
  })
291
291
  },
292
292
  {
293
- ...u,
293
+ ...d,
294
294
  description: "@-mention autocomplete over the app-declared directory, tenant-filtered.",
295
- execute: (e, t) => p.promise(async () => {
295
+ execute: (e, t) => m.promise(async () => {
296
296
  let { query: n } = e;
297
- if (v.resolveMentions === void 0) return [];
298
- let r = M(t);
299
- return (await v.resolveMentions({
297
+ if (r.resolveMentions === void 0) return [];
298
+ let i = N(t);
299
+ return (await r.resolveMentions({
300
300
  query: n,
301
301
  subject: {
302
- id: r.id,
303
- tenantId: r.tenantId
302
+ id: i.id,
303
+ tenantId: i.tenantId
304
304
  }
305
- }).catch(() => [])).filter((e) => v.crossTenant === !0 || e.tenantId === void 0 || e.tenantId === null || e.tenantId === r.tenantId).map((e) => ({
305
+ }).catch(() => [])).filter((e) => r.crossTenant === !0 || e.tenantId === void 0 || e.tenantId === null || e.tenantId === i.tenantId).map((e) => ({
306
306
  subjectId: e.subjectId,
307
307
  label: e.label
308
308
  }));
@@ -312,25 +312,25 @@ var D = b("_voltro_comment_threads", {
312
312
  kind: "json",
313
313
  data: e
314
314
  });
315
- return C({
315
+ return w({
316
316
  name: y,
317
- baseName: j,
317
+ baseName: M,
318
318
  description: "Comment threads on any app entity — replies, resolve/reopen, @-mentions with notifications, reactions, unread — live over the reactive engine.",
319
319
  permissions: ["inspect:read", "store:write"],
320
320
  declaredScopes: [B],
321
321
  extendSchema: { tables: [
322
- D,
323
322
  O,
324
323
  k,
325
- A
324
+ A,
325
+ j
326
326
  ] },
327
327
  routes: V,
328
- rpcClientDescriptors: i,
328
+ rpcClientDescriptors: a,
329
329
  inspectEndpoints: [{
330
330
  method: "GET",
331
331
  path: "/threads",
332
332
  description: "Thread volume + the most recent threads (operator surface).",
333
- handler: () => p.promise(async () => {
333
+ handler: () => m.promise(async () => {
334
334
  let e = await S("_voltro_comment_threads", void 0), t = await S("_voltro_comments", void 0), n = [...e].sort((e, t) => I(e.createdAt) > I(t.createdAt) ? -1 : 1).slice(0, 50).map((e) => ({
335
335
  id: String(e.id),
336
336
  anchor: String(e.anchor),
@@ -352,13 +352,13 @@ var D = b("_voltro_comment_threads", {
352
352
  bindDataStore: (e) => {
353
353
  b = e, x = e;
354
354
  },
355
- onActivate: (e) => p.sync(() => {
356
- v.access?.viaEntity === void 0 && v.access?.scope === void 0 && e.logger.warn("comments: no access rule declared — EVERY read and write will be refused (fail-closed). Set commentsPlugin({ access: { viaEntity } }) or { access: { scope } }."), e.logger.info("comments active", { mentions: v.resolveMentions !== void 0 });
355
+ onActivate: (e) => m.sync(() => {
356
+ r.access?.viaEntity === void 0 && r.access?.scope === void 0 && e.logger.warn("comments: no access rule declared — EVERY read and write will be refused (fail-closed). Set commentsPlugin({ access: { viaEntity } }) or { access: { scope } }."), e.logger.info("comments active", { mentions: r.resolveMentions !== void 0 });
357
357
  }),
358
- onDeactivate: () => p.sync(() => {
358
+ onDeactivate: () => m.sync(() => {
359
359
  b = void 0, x = void 0;
360
360
  })
361
361
  });
362
- }, P = () => "plugin-comments";
362
+ }, F = () => "plugin-comments";
363
363
  //#endregion
364
- export { e as CommentAccessRefused, t as CommentNotFound, n as CommentNotYours, k as commentReactionsTable, A as commentReadsTable, D as commentThreadsTable, r as commentsFeed, N as commentsPlugin, O as commentsTable, P as pluginComments };
364
+ export { e as CommentAccessRefused, t as CommentNotFound, n as CommentNotYours, A as commentReactionsTable, j as commentReadsTable, O as commentThreadsTable, i as commentsFeed, P as commentsPlugin, k as commentsTable, F as pluginComments };
package/dist/rpc.d.ts CHANGED
@@ -4,6 +4,18 @@ import { QueryProcedureDescriptor } from '@voltro/protocol';
4
4
  import { ReactivityChannel } from '@voltro/protocol';
5
5
  import { Schema } from 'effect';
6
6
 
7
+ /**
8
+ * The plugin's canonical package name — the key an app's `alias` is recorded
9
+ * under, and what `./web`'s hooks resolve their tags through.
10
+ *
11
+ * It lives in this BROWSER-SAFE module rather than in `index.ts` because both
12
+ * halves need it and only one of them may reach the server graph: `index.ts`
13
+ * passes it to `pluginInstanceName` / `baseName`, and `web.ts` passes it to
14
+ * `pluginTag`. A second copy in `web.ts` would be a second definition of the
15
+ * plugin's identity with nothing comparing them.
16
+ */
17
+ export declare const BASE_NAME = "@voltro/plugin-comments";
18
+
7
19
  /** The anchor's access check refused the caller — or no access rule is
8
20
  * declared at all (fail-closed: undeclared access is a refusal, not a pass).
9
21
  * Also the answer for a thread whose anchor row no longer resolves (a
package/dist/rpc.js CHANGED
@@ -2,7 +2,7 @@ import { CommentAccessRefused as e, CommentNotFound as t, CommentNotYours as n }
2
2
  import { Schema as r } from "effect";
3
3
  import { defineMutation as i, defineQuery as a, reactivityChannel as o } from "@voltro/protocol";
4
4
  //#region src/rpc.ts
5
- var s = o("comments"), c = r.Struct({
5
+ var s = "@voltro/plugin-comments", c = o("comments"), l = r.Struct({
6
6
  id: r.String,
7
7
  threadId: r.String,
8
8
  body: r.String,
@@ -15,22 +15,22 @@ var s = o("comments"), c = r.Struct({
15
15
  count: r.Number,
16
16
  mine: r.Boolean
17
17
  }))
18
- }), l = r.Struct({
18
+ }), u = r.Struct({
19
19
  id: r.String,
20
20
  anchor: r.String,
21
21
  status: r.Literal("open", "resolved"),
22
22
  createdBy: r.String,
23
23
  createdAt: r.String,
24
- comments: r.Array(c),
24
+ comments: r.Array(l),
25
25
  unreadCount: r.Number
26
- }), u = a({
26
+ }), d = a({
27
27
  name: "comments.list",
28
- source: s,
28
+ source: c,
29
29
  input: r.Struct({ anchor: r.String }),
30
- output: r.Array(l),
30
+ output: r.Array(u),
31
31
  error: e,
32
32
  openAccess: "delegated: the executor runs the app-declared anchor access rule (viaEntity guard or scope) and is fail-closed — an undeclared rule refuses every read"
33
- }), d = i({
33
+ }), f = i({
34
34
  name: "comments.create",
35
35
  input: r.Struct({
36
36
  anchor: r.String,
@@ -44,7 +44,7 @@ var s = o("comments"), c = r.Struct({
44
44
  }),
45
45
  error: r.Union(e, t),
46
46
  openAccess: "delegated: the executor runs the app-declared anchor access rule before any write; the author is always the calling subject"
47
- }), f = i({
47
+ }), p = i({
48
48
  name: "comments.edit",
49
49
  input: r.Struct({
50
50
  commentId: r.String,
@@ -53,7 +53,7 @@ var s = o("comments"), c = r.Struct({
53
53
  output: r.Struct({ ok: r.Boolean }),
54
54
  error: r.Union(e, t, n),
55
55
  openAccess: "author-scoped: only the comment's own author may edit, checked in the executor on top of the anchor access rule"
56
- }), p = i({
56
+ }), m = i({
57
57
  name: "comments.resolve",
58
58
  input: r.Struct({
59
59
  threadId: r.String,
@@ -62,13 +62,13 @@ var s = o("comments"), c = r.Struct({
62
62
  output: r.Struct({ ok: r.Boolean }),
63
63
  error: r.Union(e, t),
64
64
  openAccess: "delegated: anchor access rule in the executor — anyone who may read the anchor may resolve/reopen (the Liveblocks semantic)"
65
- }), m = i({
65
+ }), h = i({
66
66
  name: "comments.delete",
67
67
  input: r.Struct({ commentId: r.String }),
68
68
  output: r.Struct({ ok: r.Boolean }),
69
69
  error: r.Union(e, t, n),
70
70
  openAccess: "author-scoped, with a comments:moderate scope override — both checked in the executor on top of the anchor access rule"
71
- }), h = i({
71
+ }), g = i({
72
72
  name: "comments.react",
73
73
  input: r.Struct({
74
74
  commentId: r.String,
@@ -77,13 +77,13 @@ var s = o("comments"), c = r.Struct({
77
77
  output: r.Struct({ reacted: r.Boolean }),
78
78
  error: r.Union(e, t),
79
79
  openAccess: "delegated: anchor access rule in the executor; the toggle writes only the calling subject's own reaction row"
80
- }), g = i({
80
+ }), _ = i({
81
81
  name: "comments.markRead",
82
82
  input: r.Struct({ threadId: r.String }),
83
83
  output: r.Struct({ ok: r.Boolean }),
84
84
  error: t,
85
85
  openAccess: "self-scoped write: stamps only the calling subject's own read marker for the thread"
86
- }), _ = a({
86
+ }), v = a({
87
87
  name: "comments.mentionSearch",
88
88
  input: r.Struct({ query: r.String }),
89
89
  output: r.Array(r.Struct({
@@ -91,7 +91,7 @@ var s = o("comments"), c = r.Struct({
91
91
  label: r.String
92
92
  })),
93
93
  openAccess: "delegated: the app-declared mention resolver receives the CALLING subject and its default filters to the caller's tenant — cross-tenant subjects are structurally unreachable"
94
- }), v = [
94
+ }), y = [
95
95
  {
96
96
  tag: "comments.list",
97
97
  kind: "query",
@@ -158,4 +158,4 @@ var s = o("comments"), c = r.Struct({
158
158
  }
159
159
  ];
160
160
  //#endregion
161
- export { s as commentsFeed, v as commentsRpcClientImports, d as createDescriptor, m as deleteDescriptor, f as editDescriptor, u as listDescriptor, g as markReadDescriptor, _ as mentionSearchDescriptor, h as reactDescriptor, p as resolveDescriptor };
161
+ export { s as BASE_NAME, c as commentsFeed, y as commentsRpcClientImports, f as createDescriptor, h as deleteDescriptor, p as editDescriptor, d as listDescriptor, _ as markReadDescriptor, v as mentionSearchDescriptor, g as reactDescriptor, m as resolveDescriptor };
package/dist/web.js CHANGED
@@ -1,35 +1,37 @@
1
- import { useMutation as e, useSubscription as t } from "@voltro/client";
1
+ import { BASE_NAME as e } from "./rpc.js";
2
+ import { pluginTag as t } from "@voltro/protocol";
3
+ import { useMutation as n, useSubscription as r } from "@voltro/client";
2
4
  //#region src/web.ts
3
- var n = (n, r = "app") => {
4
- let { data: i, loading: a } = t(r, "comments.list", { anchor: n }), o = e(r, "comments.create"), s = e(r, "comments.edit"), c = e(r, "comments.resolve"), l = e(r, "comments.delete"), u = e(r, "comments.react"), d = e(r, "comments.markRead"), f = i ?? [];
5
+ var i = (n) => t(e, n), a = (e, t = "app") => {
6
+ let { data: a, loading: o } = r(t, i("list"), { anchor: e }), s = n(t, i("create")), c = n(t, i("edit")), l = n(t, i("resolve")), u = n(t, i("delete")), d = n(t, i("react")), f = n(t, i("markRead")), p = a ?? [];
5
7
  return {
6
- threads: f,
7
- pending: a,
8
- unreadCount: f.reduce((e, t) => e + t.unreadCount, 0),
9
- create: (e, t) => o.mutate({
10
- anchor: n,
11
- body: e,
12
- ...t?.threadId === void 0 ? {} : { threadId: t.threadId },
13
- ...t?.mentions === void 0 ? {} : { mentions: t.mentions }
8
+ threads: p,
9
+ pending: o,
10
+ unreadCount: p.reduce((e, t) => e + t.unreadCount, 0),
11
+ create: (t, n) => s.mutate({
12
+ anchor: e,
13
+ body: t,
14
+ ...n?.threadId === void 0 ? {} : { threadId: n.threadId },
15
+ ...n?.mentions === void 0 ? {} : { mentions: n.mentions }
14
16
  }),
15
- edit: (e, t) => s.mutate({
17
+ edit: (e, t) => c.mutate({
16
18
  commentId: e,
17
19
  body: t
18
20
  }),
19
- resolve: (e, t = !0) => c.mutate({
21
+ resolve: (e, t = !0) => l.mutate({
20
22
  threadId: e,
21
23
  resolved: t
22
24
  }),
23
- remove: (e) => l.mutate({ commentId: e }),
24
- react: (e, t) => u.mutate({
25
+ remove: (e) => u.mutate({ commentId: e }),
26
+ react: (e, t) => d.mutate({
25
27
  commentId: e,
26
28
  emoji: t
27
29
  }),
28
- markRead: (e) => d.mutate({ threadId: e })
30
+ markRead: (e) => f.mutate({ threadId: e })
29
31
  };
30
- }, r = (e, t, r = "app") => n(e, r).threads.find((e) => e.id === t), i = (e, n = "app") => {
31
- let { data: r } = t(n, "comments.mentionSearch", { query: e });
32
- return r ?? [];
32
+ }, o = (e, t, n = "app") => a(e, n).threads.find((e) => e.id === t), s = (e, t = "app") => {
33
+ let { data: n } = r(t, i("mentionSearch"), { query: e });
34
+ return n ?? [];
33
35
  };
34
36
  //#endregion
35
- export { n as useComments, i as useMentionSearch, r as useThread };
37
+ export { a as useComments, s as useMentionSearch, o as useThread };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-comments",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "Comment threads on any anchor your app can name — replies, resolve/reopen, reactions, per-subject unread — live over the existing reactivity channel, with no second push mechanism. Access delegates to your app's own guard (access.viaEntity) or a scope and fails closed when neither is declared; @-mentions are re-filtered to the caller's tenant on search and re-validated at create, then delivered through plugin-notifications when it is installed. useComments / useThread / useMentionSearch in /web, an ejectable <CommentsThread> in @voltro/ui, plus inspect endpoints and a dashboard panel.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -48,9 +48,9 @@
48
48
  "node": ">=24.0.0"
49
49
  },
50
50
  "dependencies": {
51
- "@voltro/database": "0.54.0",
52
- "@voltro/protocol": "0.54.0",
53
- "@voltro/plugin-notifications": "0.54.0",
51
+ "@voltro/database": "0.55.0",
52
+ "@voltro/protocol": "0.55.0",
53
+ "@voltro/plugin-notifications": "0.55.0",
54
54
  "effect": "^3.22.0"
55
55
  },
56
56
  "peerDependencies": {