janela 0.12.0 → 0.13.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.
@@ -0,0 +1,187 @@
1
+ ---
2
+ Date: 2026-10-02
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 010, ADR 012, ADR 014, ADR 017, ADR 019, ADR 025, ADR 032, ADR 033, ADR 034, ADR 037, ADR 041
5
+ Triggers:
6
+ - proposing an MCP server, an agent tool or any machine facing surface for Janela
7
+ - an agent needs to read or arrange a frame without a browser
8
+ - adding a dependency on an MCP library
9
+ - deciding whether Janela should detect, register or install anything in a host's own tooling
10
+ - running a Janela query outside a controller, from a tool, a job or a console
11
+ - changing what an agent may do to a pane
12
+ Topics: ai, agents, mcp, tools, authorisation, scope, host-integration, frames, dependencies, 1.0
13
+ ---
14
+
15
+ # ADR 053: An Agent Reaches Janela Through Tools the Host Scopes, and Janela Registers None
16
+
17
+ ## Context
18
+
19
+ Janela dashboards are meant to be composed by agents, and an agent in a
20
+ host application has no way in. Frames and panes are rows (ADR 012), so
21
+ the only route is `Janela::Frame` and `Janela::Pane` written from a
22
+ console. A host that already runs an MCP server writes its own wrappers
23
+ by hand, and a host without one gets nothing. #73 asks for an MCP
24
+ surface, and ADR 010 named it as "one ADR away" and as one of the two
25
+ conditions for shipping Jan.
26
+
27
+ The issue proposed that Janela detect a host's MCP server and offer to
28
+ install tools into it. Four facts, measured on the demo before this was
29
+ written, change what that should mean.
30
+
31
+ **Reading a pane outside a controller reads everything.** The refusal in
32
+ ADR 032 lives in `Janela::ApplicationController`, where `policy_scope`
33
+ is asked. `Query#result` underneath takes an optional `on:` relation,
34
+ and without it reads the model's default scope. With two orders in the
35
+ demo, `pane.query.result` returned both statuses, and
36
+ `pane.query.result(on: Order.where(status: "paid"))` returned one. A tool
37
+ that called the first form would show an agent, and so a person, rows
38
+ the host never scoped, with no error. This is the same shape as ADR 034:
39
+ a thing that runs outside a request and has to be told what may be read.
40
+
41
+ **The host's scope is an answer given at the point of asking.** ADR 019
42
+ found that a decision needing the current user and tenant belongs inside
43
+ a request rather than in a setting, and ADR 032 rejected a global
44
+ `Janela.scope = ->(model, controller)` for it. An MCP call is not a
45
+ request to Janela's controllers. Who is asking arrives through whatever
46
+ authentication the host's own MCP server has, which Janela does not own
47
+ and cannot see.
48
+
49
+ **A pane row already refuses what it should.** A pane naming a measure or
50
+ model that is not declared is invalid, and says so in words an agent can
51
+ act on: `Measure "nonsense" is not a measure of Order` and `Model
52
+ "no_such_model" is not a janela model`. The tools do not need a
53
+ validation layer of their own.
54
+
55
+ **Tools can be built from data.** The official Ruby SDK, `mcp` 1.6.1,
56
+ builds a tool from a name, a description, a JSON schema and a block
57
+ (`MCP::Tool.define`), and hands the caller's context to the block as
58
+ `server_context`. Other libraries take the same four things. A tool
59
+ definition is therefore plain data plus a call, and none of it needs to
60
+ live in an MCP library to be written once.
61
+
62
+ One more fact corrects the issue. #73 describes the install task as "in
63
+ the same spirit as `rails janela:install:skill`". That task does not
64
+ exist. ADR 010 records the skill, the install task and the agent
65
+ definition as not implemented, and `docs/skills/` is absent. This would
66
+ be the first install task Janela has, not the second.
67
+
68
+ ### What was considered
69
+
70
+ **Janela serves MCP itself**, as an engine endpoint. Rejected. A served
71
+ endpoint needs authentication, a notion of who is calling and a way to
72
+ answer ADR 032's question for them, and Janela has none of the three: its
73
+ controllers are exactly as authenticated as the host's (ADR 010). Adding
74
+ them is the subsystem ADR 001 refused, and getting scope wrong shows one
75
+ tenant's numbers to another.
76
+
77
+ **Detect the host's MCP server and install into it**, as #73 proposed.
78
+ Rejected for the reason ADR 034 rejected detecting the kind of host: the
79
+ signal does not distinguish anything. A host's MCP setup is a gem, an
80
+ endpoint or a hand-written server, and the one thing Janela can reliably
81
+ know is none of those. It would also be Janela editing a host's tooling,
82
+ which ADR 010 says it never does.
83
+
84
+ **A `janela:install:mcp` task that writes tool files into the host.**
85
+ Rejected. Once copied the files are the host's, they drift from the
86
+ gem's public API, and the doctor would have to grow a check for each
87
+ release to say so. A host that wants the shape reads it in the guide and
88
+ owns the ten lines.
89
+
90
+ **A global scope setting**, so the tools need no argument. Rejected on
91
+ ADR 019 and ADR 032, above.
92
+
93
+ **`mcp` as a dependency of the gem.** Rejected. It is the right library
94
+ today and is not the only one a host may use, so a hard dependency would
95
+ make Janela's choice the host's. It stays out of the gemspec, the way the
96
+ demo's markdown renderer does, and the adapter below is optional.
97
+
98
+ **Writing and reading in one step.** Rejected as the shipped shape, not
99
+ as an idea. The write tools change records a person arranged, so they are
100
+ off until asked for.
101
+
102
+ ## Decision
103
+
104
+ **Janela ships its agent tools as plain Ruby that the host builds with a
105
+ scope and registers wherever it likes. Janela registers nothing,
106
+ detects nothing and serves nothing.**
107
+
108
+ ```ruby
109
+ tools = Janela::Tools.new(scope: ->(model) { policy_scope(model) }, write: true)
110
+
111
+ tools.all # name, description, input_schema, read_only
112
+ tools.call("add_pane", frame_id: 3, model: "orders", measure: "revenue")
113
+ ```
114
+
115
+ **`scope:` is required and has no default.** It is a callable that takes
116
+ a model class and returns a relation: the same answer `policy_scope`
117
+ gives. Leaving it out raises `Janela::Unscoped` when the tools are built,
118
+ not when one is called, which is ADR 032's refusal moved to the place
119
+ this surface begins. Every read the tools make passes the relation it
120
+ returns as `on:`, and frames and panes are read through the same callable
121
+ (`scope.call(Janela::Frame)`). It is an argument and not a setting,
122
+ because it is built where the host knows who is asking, as ADR 034's
123
+ `scope_for` is.
124
+
125
+ **Read tools first, and writes only when asked for.** The 1.0 set:
126
+
127
+ | Tool | Does |
128
+ | --- | --- |
129
+ | `describe_vocabulary` | The models, measures, dimensions, renderers and granularities `Janela.definitions` declares, so an agent offers only what can be asked |
130
+ | `list_frames` | Id, name, owner and pane count of the frames the scope returns |
131
+ | `get_frame` | One frame with its panes in position order |
132
+ | `read_pane` | A pane's values, through the host's scope, with `q[...]` filters bounded as ADR 025 bounds them |
133
+ | `add_pane`, `update_pane`, `remove_pane`, `move_pane` | Only with `write: true` |
134
+
135
+ Inputs are the pane's own attributes and the README's own names. There is
136
+ no agent only vocabulary (ADR 010). A rejected row returns the pane's
137
+ validation messages unchanged. A frame is found or made by the host with
138
+ `Frame.for` (ADR 041) or by an analyst on the engine's pages, so there is
139
+ no `create_frame` in 1.0.
140
+
141
+ **A tool definition is data, and an adapter is a few lines the host
142
+ owns.** `Janela::Tools#all` carries what any library needs. The guide
143
+ shows the mapping for the official `mcp` SDK, and a host using another
144
+ writes the same few lines. Janela adds no MCP library to its gemspec.
145
+
146
+ **Snapshots are not in 1.0.** A snapshot persists and publishes, so it
147
+ needs an owner (ADR 033) and a scope the caller has named (ADR 034). That
148
+ is two more decisions an agent should not be making by default, and it
149
+ waits for a host that wants it.
150
+
151
+ **Jan still does not ship.** ADR 010's second condition, that an MCP
152
+ surface exists, is met by this. Its first reason, that a skill loaded
153
+ agent already does the work, has not been tested, because the skill was
154
+ never built. The skill ships first and teaches these tools. An agent
155
+ definition is revisited after a host has used both, and ADR 010 is not
156
+ superseded here.
157
+
158
+ ## Consequences
159
+
160
+ - **It ships as 0.13.0, with its own short soak.** This adds public
161
+ surface, and ADR 037 says anything that changes the surface resets the
162
+ soak clock in #71. The earliest 1.0 tag moves by the length of that
163
+ soak. The rule for what 1.0 then contains is set now, before anyone has
164
+ seen the result: if a real host has exercised the write tools and found
165
+ nothing wrong, 1.0 ships with them. If no host has used them by the end
166
+ of the soak, 1.0 ships read only and the write tools wait for 1.1.
167
+ Freezing four schemas that nobody has tried is what a soak is for
168
+ avoiding.
169
+ - **The tool names and input schemas join the contract at 1.0**, so the
170
+ set is small on purpose and every name is a verb and a README noun. A
171
+ tool added after 1.0 is a minor release. A tool changed is a major one.
172
+ - **A host does the registering.** That is a few lines in the place its
173
+ MCP server already lives, and the guide says where. A host with no MCP
174
+ server gets nothing from this, which is the honest answer: Janela does
175
+ not run servers.
176
+ - **The first test is the measured failure.** A tool built with a scope
177
+ that returns `Order.none` reads no values, and tools built without a
178
+ scope raise. Both belong in the suite before the code.
179
+ - **No doctor check.** Construction raises, so there is nothing for the
180
+ doctor to see that the raise does not say (ADR 035).
181
+ - **The pane `order` option (#75) reaches agents for free**, since the
182
+ write tools take a pane's attributes. It does not change this ADR.
183
+ - **What would change this decision:** two hosts writing the same
184
+ adapter by hand, which would earn a shipped one for that library; or a
185
+ host with no MCP server that needs one, which would be the day Janela
186
+ is asked to run something and has to answer ADR 032's question for
187
+ itself.
@@ -23,26 +23,26 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
23
23
  | **Vision, scope, forkability** | 001, 010, 012, 037, 052 |
24
24
  | **Open-source & host-decoupling** | 001, 022, 036, 041, 052 |
25
25
  | **DSL & query layer** | 002, 006, 007, 020, 025, 038, 044, 049, 051 |
26
- | **Dependencies** | 002, 003, 004, 006, 017, 025 |
27
- | **Authorisation** | 002, 003, 004, 009, 017, 019, 022, 032, 033, 034, 035, 039, 040, 048 |
26
+ | **Dependencies** | 002, 003, 004, 006, 017, 025, 053 |
27
+ | **Authorisation** | 002, 003, 004, 009, 017, 019, 022, 032, 033, 034, 035, 039, 040, 048, 053 |
28
28
  | **Performance & storage** | 007, 017, 025, 048, 051 |
29
29
  | **Ordering & formatting** | 007, 020, 038 |
30
30
  | **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025, 040, 043, 044, 045, 048, 049 |
31
31
  | **Layouts & views** | 011, 012, 016, 018, 020, 027, 039, 047, 050 |
32
32
  | **CSS & styling** | 016, 018, 023, 026, 027, 036, 042, 046, 047, 050 |
33
- | **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043, 044, 047, 048, 050, 051 |
33
+ | **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043, 044, 047, 048, 050, 051, 053 |
34
34
  | **Naming rule** | 014, 023, 036 |
35
35
  | **JavaScript delivery & charts** | 004, 006, 026, 042, 046, 047 |
36
36
  | **Time dimensions** | 006, 025, 045 |
37
37
  | **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041, 045, 047, 050, 051 |
38
38
  | **Snapshots & publishing** | 009, 020, 028, 033, 034, 051 |
39
- | **AI agents & guidance** | 010, 015, 021 |
39
+ | **AI agents & guidance** | 010, 015, 021, 053 |
40
40
  | **The doctor & checks** | 021, 025, 032, 033, 035 |
41
41
  | **Releases & upgrades** | 015, 021, 032, 034, 035, 036, 037, 042, 052 |
42
42
  | **Accessibility & keyboard** | 024, 042, 045, 046, 049 |
43
43
  | **Security** | 003, 025, 028, 031, 032, 034, 035, 044, 052 |
44
44
  | **Testing** | 003 |
45
- | **Roadmap & planning** | 001, 037, 045, 046, 052 |
45
+ | **Roadmap & planning** | 001, 037, 045, 046, 052, 053 |
46
46
 
47
47
  ## Chronological
48
48
 
@@ -100,7 +100,8 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
100
100
  | 050 | A Single Value's Prominence Is One of Three Steps, and Unset Changes Nothing | 2026-09-30 | Accepted |
101
101
  | 051 | A Table Can Carry Companion Columns, and a Fact Is Shown Only When It Is Shared | 2026-09-30 | Accepted |
102
102
  | 052 | Two People Cut Releases, Reports Go Private, and Support Is Best Effort | 2026-09-30 | Accepted |
103
+ | 053 | An Agent Reaches Janela Through Tools the Host Scopes, and Janela Registers None | 2026-10-02 | Accepted |
103
104
 
104
105
  ## Next number
105
106
 
106
- Next ADR: 053
107
+ Next ADR: 054
@@ -36,9 +36,10 @@ without asking first:
36
36
  - A snapshot, read through `policy_scope(Janela::Snapshot)`.
37
37
 
38
38
  A pane rendered inline in your own page runs in your request, so the
39
- same scope applies there as in the engine's controllers. Nothing is
40
- calculated in a background context where the current tenant would have
41
- gone missing.
39
+ same scope applies there as in the engine's controllers, so a live pane
40
+ is never calculated where the current tenant would have gone missing. A
41
+ scheduled snapshot is the one thing that runs in the background, and it
42
+ is told whose it is (see Snapshots below).
42
43
 
43
44
  ## With Pundit
44
45
 
@@ -69,6 +70,23 @@ class ApplicationController < ActionController::Base
69
70
  end
70
71
  ```
71
72
 
73
+ A snapshot is read through its own scope as well. Without a policy for it
74
+ Pundit raises, and a hand rolled `policy_scope` that does not name it
75
+ would hand every tenant every snapshot, so write the second one too:
76
+
77
+ ```ruby
78
+ # app/policies/janela/snapshot_policy.rb
79
+ module Janela
80
+ class SnapshotPolicy < ApplicationPolicy
81
+ class Scope < ApplicationPolicy::Scope
82
+ def resolve
83
+ scope.where(owner: Current.account)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ ```
89
+
72
90
  Your own models keep the policies they already have. Janela calls
73
91
  `policy_scope(Order)` and gets whatever `OrderPolicy::Scope` returns.
74
92
 
@@ -85,7 +103,7 @@ class ApplicationController < ActionController::Base
85
103
  private
86
104
  def policy_scope(model)
87
105
  case model.name
88
- when "Janela::Frame" then model.where(owner: ActsAsTenant.current_tenant)
106
+ when "Janela::Frame", "Janela::Snapshot" then model.where(owner: ActsAsTenant.current_tenant)
89
107
  else model.all # acts_as_tenant has already scoped your own models
90
108
  end
91
109
  end
data/docs/roadmap.md CHANGED
@@ -8,7 +8,7 @@ Topics: roadmap, releases, scope, planning
8
8
 
9
9
  <svg viewBox="0 0 680 360" width="100%" role="img" aria-labelledby="vista-title vista-desc" class="vista-art" style="display: block; margin: 1.75rem 0; border-radius: 10px;">
10
10
  <title id="vista-title">Vista</title>
11
- <desc id="vista-desc">Sea and sky with a horizon across them. The near water is dark, because every issue in 1.0 has shipped, and three lights sit far off at the horizon for the work still in sight past it. A low sun rises and sets on the horizon as the pointer moves up and down, and never climbs higher: the sky above is empty, because what is not coming is not in view.</desc>
11
+ <desc id="vista-desc">Sea and sky with a horizon across them. The near water is dark, because every issue in 1.0 has shipped, and five lights sit far off at the horizon for the work still in sight past it. A low sun rises and sets on the horizon as the pointer moves up and down, and never climbs higher: the sky above is empty, because what is not coming is not in view.</desc>
12
12
 
13
13
  <style>
14
14
  .vista-art .v-far { transform: translate3d(calc(var(--vitral-shift-x, 0) * 13px), calc(var(--vitral-shift-y, 0) * 7px), 0); transition: transform .45s cubic-bezier(.2,.7,.3,1); }
@@ -89,6 +89,10 @@ Topics: roadmap, releases, scope, planning
89
89
  <circle cx="322" cy="201" r="1.5" fill="#FFF3DC" opacity="0.55"/>
90
90
  <circle cx="540" cy="202.5" r="8" fill="url(#vLamp)" opacity="0.2"/>
91
91
  <circle cx="540" cy="202.5" r="1.6" fill="#FFF3DC" opacity="0.55"/>
92
+ <circle cx="112" cy="201.5" r="8" fill="url(#vLamp)" opacity="0.2"/>
93
+ <circle cx="112" cy="201.5" r="1.5" fill="#FFF3DC" opacity="0.55"/>
94
+ <circle cx="430" cy="202" r="8" fill="url(#vLamp)" opacity="0.2"/>
95
+ <circle cx="430" cy="202" r="1.6" fill="#FFF3DC" opacity="0.55"/>
92
96
  </g>
93
97
 
94
98
  <g class="v-glint" stroke="#FFE2A6" stroke-linecap="round">
@@ -109,7 +113,7 @@ Topics: roadmap, releases, scope, planning
109
113
  Janela is alpha. It works, it is tested against a real Rails application
110
114
  in a real browser, and its public surface has changed in three of the
111
115
  last four releases. This page says what has to be true before that stops,
112
- what is in the next release, and what is deliberately not coming. The
116
+ what is left before 1.0, and what is deliberately not coming. The
113
117
  reasoning behind it is ADR 037.
114
118
 
115
119
  ## What 1.0 means
@@ -128,11 +132,10 @@ change in any release, and every change of that kind carries an entry in
128
132
  `UPGRADING.md`.
129
133
 
130
134
  **A pane can be read.** Janela draws tables, bars, lines, doughnuts and
131
- pies. A table row still cannot carry context beside its label, a single
132
- number has no way to say how prominent it is, and a chart takes whatever
133
- height its width gives it whether or not that suits the page. Those are
134
- not extra features. They are the 5% not finished, and most of them were
135
- found by people installing the gem rather than reading it.
135
+ pies. A table row can carry context beside its label, a single number says
136
+ how prominent it is, and a chart has a height that suits the page. Those
137
+ were not extra features. They were the 5% not finished, and most of them
138
+ were found by people installing the gem rather than reading it.
136
139
 
137
140
  **The doctor can be trusted.** `rails janela:doctor` checks an
138
141
  installation for the mistakes that produce a dashboard showing numbers
@@ -144,17 +147,20 @@ working is a check nobody should rely on.
144
147
 
145
148
  The near ground is clear. Every issue that was in 1.0 has shipped: chart
146
149
  height, a ratio measure, single value prominence, companion columns, Sprockets
147
- hosts, and how the project is run. What 1.0 waits on now is time with real
148
- installs, since much of that surface was added in the last days and has only
149
- met this repository. When nothing surprising comes back, the surface is
150
- frozen and 1.0 is tagged.
150
+ hosts, and how the project is run. 0.13.0 is out, and adds tools an agent
151
+ can use (ADR 053). What 1.0 waits on now is a week of real installs ([#71](https://github.com/retail-tasker/janela/issues/71)),
152
+ since much of that surface was added in the last days and has only met this
153
+ repository. When nothing surprising comes back, the surface is frozen and 1.0
154
+ is tagged. The agent tools are the one new piece of surface: the read tools are
155
+ frozen at 1.0, and the write tools are frozen with them only if a real host has
156
+ used them by the end of that week. Otherwise they wait for 1.1.
151
157
 
152
158
  Progress is tracked on the
153
159
  [1.0 milestone](https://github.com/retail-tasker/janela/milestone/2).
154
160
 
155
161
  ## Horizonte
156
162
 
157
- The three lights far off at the horizon. Wanted, not blocking a stable
163
+ The lights far off at the horizon. Wanted, not blocking a stable
158
164
  release, and being on this list is not a refusal.
159
165
 
160
166
  - **Drilling down on time panes** ([#65](https://github.com/retail-tasker/janela/issues/65)).
@@ -162,6 +168,12 @@ release, and being on this list is not a refusal.
162
168
  that pane itself to the weeks within it is a different gesture, with
163
169
  its own questions about getting back out and about the URL, and it
164
170
  needs a decision record before any code.
171
+ - **Telling a frame to refresh** ([#68](https://github.com/retail-tasker/janela/issues/68)).
172
+ One action a host's own timer or push can call, and no timer or stream of
173
+ Janela's own (ADR 048). Additive, so it can land after 1.0.
174
+ - **Vitral's own palette** ([#67](https://github.com/retail-tasker/janela/issues/67)).
175
+ The optional theme setting its own series colours instead of Janela's
176
+ neutral ones.
165
177
  - **A command palette for the demo** ([#41](https://github.com/retail-tasker/janela/issues/41)).
166
178
  The demo site, not the gem.
167
179
  - **A scroll drift on the gallery page** ([#45](https://github.com/retail-tasker/janela/issues/45)).
@@ -178,7 +190,9 @@ run does it better.
178
190
  Natural-language query. A separate data warehouse. A row-level-security
179
191
  subsystem, because your application already has Pundit or CanCanCan and
180
192
  Janela reads through it. A refresh-scheduling interface, because you
181
- already have a scheduler and snapshots are an ActiveJob. An embedding
193
+ already have a scheduler and snapshots are an ActiveJob (a way to tell a
194
+ frame to refresh, which your scheduler can call, is
195
+ [#68](https://github.com/retail-tasker/janela/issues/68)). An embedding
182
196
  SDK. A mobile application. Print and paginated reports. A drag-and-drop
183
197
  visual dashboard designer, though frames and panes are database records,
184
198
  so an application can build its own editor on top of them.
data/docs/theming.md CHANGED
@@ -82,6 +82,7 @@ the names a theme spends most of its time on.
82
82
  | `janela-legend` | `<table>` | its legend, one row of swatch, button and value per slice |
83
83
  | `janela-swatch` | `<span>` | the colour beside a legend label |
84
84
  | `janela-empty` | `<p>` | a pane whose query returned nothing |
85
+ | `janela-muted` | `<p>` | the note under a ring that could not be drawn and became a table (ADR 046) |
85
86
  | `janela-error` | `<p>` | a pane that could not be read |
86
87
  | `janela-content` | `<div>` | a stored pane holding words or a host partial rather than a query (ADR 039) |
87
88
  | `janela-content-heading` | `<h2>` | a text pane's heading |
@@ -121,8 +122,9 @@ here rather than being left for each host to write.
121
122
  `janela.css` also styles Janela's own pages, the frame index and the
122
123
  editing forms: `janela-page`, `janela-card`, `janela-button`,
123
124
  `janela-form`, `janela-field`, `janela-list`, `janela-crumb`,
124
- `janela-flash` and the rest. They are scoped under `janela-page`, which
125
- only the engine's own layout sets, so they cannot touch your pages.
125
+ `janela-flash` and the rest. They are plain class names that the engine's
126
+ own pages use, not scoped to `janela-page`, so they affect your pages
127
+ only if your own markup uses the same names. Avoid them.
126
128
 
127
129
  **Those names may change in any release.** They are the engine's own
128
130
  chrome rather than an interface. Restyle them if you want Janela's
@@ -140,8 +142,8 @@ Janela.theme = "midnight"
140
142
 
141
143
  The name is resolved against your own asset paths, so `midnight.css` in
142
144
  your application works exactly as the gem's own `vitral` does. A name
143
- that resolves to nothing raises rather than quietly rendering an
144
- unthemed page.
145
+ that your asset pipeline cannot find raises when the page renders,
146
+ rather than quietly rendering an unthemed one.
145
147
 
146
148
  That setting links the theme into **Janela's own pages**. Your pages load
147
149
  whatever your layout says, so if you want the same look around a pane you
data/lib/janela/doctor.rb CHANGED
@@ -9,7 +9,7 @@ module Janela
9
9
 
10
10
  # Run in this order, and each one names the finding it produces: a host
11
11
  # silences a check by that name (ADR 021).
12
- CHECKS = %i[stale_identifiers unmounted_engine unmigrated_tables unregistered_controllers
12
+ CHECKS = %i[stale_identifiers unmounted_engine unmigrated_tables unmigrated_columns unregistered_controllers
13
13
  through_dimensions_without_an_allowlist unscoped_reads frames_nobody_will_own
14
14
  snapshots_nobody_will_see unauthenticated_endpoints
15
15
  hardcoded_disallowed_predicates].freeze
@@ -21,10 +21,22 @@ module Janela
21
21
  "janela/dashboard_controller" => "janela/frame_controller",
22
22
  "janela/dashboard_controller.js" => "janela/frame_controller.js",
23
23
  "Janela::DashboardHelper" => "Janela::FramesHelper",
24
- "Janela::PanesController" => "Janela::QueriesController",
25
24
  "Janela::SnapshotPanesController" => "Janela::SnapshotQueriesController"
26
25
  }.freeze
27
26
 
27
+ # A name that was renamed away and later given back to something else, so
28
+ # finding it cannot say which one the host means. An error would send a
29
+ # host patching the stored-pane form to a controller without pane_params,
30
+ # and the extra param would silently stop saving (#74). A warning says what
31
+ # the name means now and leaves the judgement to the host.
32
+ REUSED = {
33
+ "Janela::PanesController" =>
34
+ "This name was the controller that renders a pane's query until 0.7, when it became " \
35
+ "Janela::QueriesController. Since 0.12 it is the stored-pane form's controller again, " \
36
+ "and it owns pane_params. A patch to the form belongs here; a patch to how a query " \
37
+ "renders belongs on Janela::QueriesController."
38
+ }.freeze
39
+
28
40
  SEARCHED = %w[app config lib].freeze
29
41
  READABLE = %w[.rb .erb .js .erb.html .html.erb .haml .slim .yml].freeze
30
42
 
@@ -79,14 +91,26 @@ module Janela
79
91
  end
80
92
 
81
93
  def stale_identifiers
82
- RENAMED.filter_map do |old, new|
83
- files = source_files.select { |file| file.read.include?(old) }
84
- next if files.empty?
94
+ renamed = RENAMED.filter_map do |old, new|
95
+ next unless (files = files_naming(old)).any?
85
96
 
86
- Finding.new(severity: :error,
87
- summary: "#{old} is now #{new}",
88
- detail: files.map { |file| " #{file.relative_path_from(@root)}" }.join("\n"))
97
+ Finding.new(severity: :error, summary: "#{old} is now #{new}", detail: listed(files))
89
98
  end
99
+ reused = REUSED.filter_map do |name, meaning|
100
+ next unless (files = files_naming(name)).any?
101
+
102
+ Finding.new(severity: :warning, summary: "#{name} has meant two things",
103
+ detail: " #{meaning}\n#{listed(files)}")
104
+ end
105
+ renamed + reused
106
+ end
107
+
108
+ def files_naming(identifier)
109
+ source_files.select { |file| file.read.include?(identifier) }
110
+ end
111
+
112
+ def listed(files)
113
+ files.map { |file| " #{file.relative_path_from(@root)}" }.join("\n")
90
114
  end
91
115
 
92
116
  def unmounted_engine
@@ -116,6 +140,40 @@ module Janela
116
140
  nil # no database to ask yet
117
141
  end
118
142
 
143
+ # The columns a release added to a table that already existed. A host that
144
+ # upgraded and did not run the migration has the table, so the check above
145
+ # passes, and meets an error the first time a pane form or a stored pane
146
+ # reads the column. Only the tables that exist are asked: a missing table
147
+ # is that check's finding, and listing every column of it here would say
148
+ # the same thing twice.
149
+ ADDED_COLUMNS = {
150
+ "Janela::Frame" => %w[key default_model default_where],
151
+ "Janela::Pane" => %w[kind heading body link partial height prominence companions],
152
+ "Janela::Snapshot" => %w[owner_type owner_id]
153
+ }.freeze
154
+
155
+ def unmigrated_columns
156
+ return unless engine_mounted?
157
+
158
+ absent = ADDED_COLUMNS.filter_map do |model_name, columns|
159
+ model = model_name.constantize
160
+ next unless model.table_exists?
161
+
162
+ missing = columns - model.column_names
163
+ "#{model.table_name} (#{missing.join(', ')})" if missing.any?
164
+ end
165
+ return if absent.empty?
166
+
167
+ Finding.new(severity: :error,
168
+ summary: "#{absent.to_sentence} #{absent.one? ? 'is' : 'are'} missing columns a release added",
169
+ detail: " The tables exist, so the migrations from an earlier release ran, but a later one has not.\n" \
170
+ " Run:\n" \
171
+ " bin/rails janela:install:migrations\n" \
172
+ " bin/rails db:migrate")
173
+ rescue StandardError
174
+ nil # no database to ask yet
175
+ end
176
+
119
177
  def engine_mounted?
120
178
  Rails.application.routes.routes.any? { |route| route.app.respond_to?(:app) && route.app.app == Janela::Engine }
121
179
  end