table-editor 0.1.0

A local HTTP server and browser bundle for editing a repository's JSONL tables
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
# TableEditor

[![CI](https://github.com/RKeelan/TableEditor/actions/workflows/ci.yml/badge.svg)](https://github.com/RKeelan/TableEditor/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/RKeelan/TableEditor/blob/main/LICENSE)

`table-editor` is a loopback HTTP server and an embedded browser bundle for editing a repository's `Data/*.jsonl` tables. A repository describes its own tables in Rust—one `TableLogic` implementation per table and one `App` implementation for the collection—and gets the server, the routes, the file I/O, and the UI from the crate.

The browser holds no per-repository knowledge. Every table sends a column schema with its rows, and the editor renders whatever that schema describes, so a new table needs Rust and no JavaScript. The bundle's sources are in `Web/`, and the page they build ships inside the published crate, so a consumer needs Rust and nothing else. The crate is used by the author's private writing repositories, each of which serves its own tables under its own name and port.

## Depending on the crate

```toml
table-editor = "=0.1.0"
```

An exact version rather than a range, matching the policy the crate's own dependencies follow: an upgrade is a deliberate edit, and the schema the server sends is a contract with the page shipped beside it.

The default `server` feature is the editor: the HTTP server, the launcher, the column schema, the loopback probes, and the embedded bundle. A crate that only reads and writes the table files turns it off:

```toml
table-editor = { version = "=0.1.0", default-features = false }
```

What remains is the file format alone—the `jsonl` codec and the `ParseError`, `ValidationError`, and `ApiError` types—which depends on nothing but `serde` and `serde_json`. Neither `clap`, `tiny_http`, nor `anyhow` is built in that configuration.

`windows-sys` is a dependency on Windows alone, under the same feature, and only for the two calls that stop a detached worker inheriting the standard handles of the command that launched it. Windows hands a child every inheritable handle its parent holds whatever the child's own handles are set to, so without those calls a caller piping `app web` into anything would wait on a pipe the server holds open for as long as it runs. It is taken with the two feature flags those calls need and nothing else.

The minimum supported Rust version is 1.88, which is where `if let` chains landed. It is established by building on it rather than by guessing, and a release that needs a later compiler says so in `rust-version`.

### Work spanning this repository and a consumer

To try a change that is not released yet, point the registry name at a commit or a checkout from the consumer's own manifest:

```toml
[patch.crates-io]
table-editor = { git = "https://github.com/RKeelan/TableEditor.git", rev = "<sha>" }
# or, for a sibling checkout
table-editor = { path = "../TableEditor" }
```

The consumer's `table-editor = "=0.1.0"` stays as it is; the patch decides what that resolves to. A patched checkout serves whatever bundle that checkout has built, so run `./Deploy.ps1` there first or the editor's page is the placeholder. Keep the stanza in a gitignored `.cargo/config.toml` where it should not reach the consumer's history.

## A consumer

One table, one app, and the subcommand that runs the editor:

```rust
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
use serde::{Deserialize, Serialize};
use table_editor::{
    ApiError, App, Column, Context, NewRow, Schema, Server, ServerArgs, Table, TableLogic,
    ValidationError,
};

#[derive(Serialize, Deserialize)]
struct Book {
    title: String,
    year: u32,
}

struct Books;

impl TableLogic for Books {
    type Row = Book;

    fn name(&self) -> &'static str {
        "books"
    }

    fn file(&self) -> &'static str {
        "Books.jsonl"
    }

    fn title(&self) -> &'static str {
        "Books"
    }

    fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
        Ok(Schema::new([
            Column::string("title", "Title"),
            Column::number("year", "Year"),
        ])
        .new_row(NewRow::new().with("title", "").with("year", 0)))
    }

    fn validate(&self, rows: &[Book], _ctx: &Context) -> Result<Vec<ValidationError>, ApiError> {
        Ok(rows
            .iter()
            .enumerate()
            .filter(|(_, row)| row.title.trim().is_empty())
            .map(|(idx, _)| ValidationError::field(idx + 1, "title", "title is required"))
            .collect())
    }

    fn derive(
        &self,
        rows: &[Book],
        _ctx: &Context,
    ) -> Result<Vec<serde_json::Value>, ApiError> {
        Ok(rows
            .iter()
            .map(|row| serde_json::json!({ "caption": format!("{} ({})", row.title, row.year) }))
            .collect())
    }
}

struct Library {
    books: Books,
}

impl App for Library {
    fn name(&self) -> &str {
        "Library"
    }

    fn tables(&self) -> Vec<&dyn Table> {
        vec![&self.books]
    }
}

#[derive(Parser)]
#[command(name = "library")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Edit the tables in a browser.
    Web(ServerArgs),
}

fn main() -> anyhow::Result<()> {
    // Parsing through an augmented command puts this app's own defaults into
    // the help for the table argument and --port.
    let command = ServerArgs::augment_help(Cli::command(), "books", 8788);
    let cli = Cli::from_arg_matches(&command.get_matches())?;

    match cli.command {
        Command::Web(args) => Server::new(Library { books: Books })
            .default_port(8788)
            .run(args),
    }
}
```

`Cli::parse()` works as well; it just leaves the two arguments describing the crate's defaults rather than this app's.

`augment_help` walks the whole command tree, so it does not matter where `ServerArgs` was flattened. It rewrites a command only where that command holds both `table` and `port` and both still carry this crate's own help, which is exactly what flattening `ServerArgs` leaves behind. A repository's own `--port` on some other subcommand is left as it was, and so is one whose help the repository has already rewritten. Nothing but the help text changes.

The consumer brings its own `anyhow`, `clap`, `serde`, and `serde_json`; the crate re-exports none of them.

`parse` and `serialize` default to plain JSONL, so a table whose row type serializes the way it is stored implements neither. `derive` and `siblings` default to nothing.

`Table` is the object-safe façade the router dispatches through. A blanket implementation covers every `TableLogic`, so nothing outside the crate implements `Table` directly—doing so would collide with the blanket implementation.

## Files

All file I/O goes through `Context`, which resolves the `Data/` directory by walking up from the process's working directory. `read` treats a missing file as a failure and `read_optional` treats it as absent, which is what a sibling table wants: a cross-check against a table that is not there is skipped rather than fatal.

A context reads each file from disk once and answers later asks for it from what it read. A context is built per request, so this is a per-request view rather than a cache that outlives one: a table whose `schema`, `validate`, `derive`, and `siblings` all consult the same sibling pay for one read and see one version of it, however the file changes underneath them, and the next request reads it afresh. The view holds even when the context is shared between threads, since a second reader waits on a read already in flight rather than starting one of its own. Parsing still happens per call, since the rows are handed out by value. A `write` replaces what the context has read, so a read after a write sees what was written.

What was read is remembered under the file name as it was spelled rather than the path it resolves to, so two spellings of one file would be read twice. A table's file comes from `TableLogic::file`, which is one string and a bare name, so a table and everything cross-checking against it name the file the same way by construction.

A table's file is a bare name: no directory separators, nothing absolute, and not `.` or `..`. Every file is resolved against the one `Data/` directory, and building a `Server` over a table that names anything else panics rather than serving it.

A table's own file must exist before the editor can open the table. The editor edits a table, it does not create one, so `GET /api/<table>` on a table whose file is missing is a 500 naming the file. A sibling table that is absent only costs the checks that consult it.

`write` puts the new text in a sibling temporary file and renames it over the target, so an interrupted write leaves the previous table intact rather than a truncated one.

## The API

`GET /api/app` describes the shell:

```json
{ "name": "Library",
  "views": [{ "view": "on-loan", "title": "On loan" }],
  "tables": [{ "table": "books", "title": "Books" }],
  "front": { "view": "on-loan" } }
```

A `subtitle` is included when the app supplies one, and omitted otherwise. So are `views`, for an app that serves none, and `front`, for one that leaves its front page alone—which means the first table. An app of tables alone therefore sends `name`, `subtitle` and `tables`, and a bundle that knows nothing of views has everything it needs.

Three endpoints serve each table:

* `GET /api/<table>` returns `{ "schema": …, "rows": [ … ], "derived": [ … ], "errors": [ … ], "siblings": … }`. `rows` is the stored table, `derived` parallels it index for index, `errors` is the validation, and `siblings` is whatever cross-table data the table supplies.
* `PUT /api/<table>` takes `{ "rows": [ … ] }`, writes those rows, and returns `{ "derived": [ … ], "errors": [ … ] }`. A validation error never refuses the write: the editor persists what it is given and shows the errors beside the cells.
* `POST /api/<table>/derive` takes the same body and returns the same payload without writing.

A validation error is `{ "line": 3, "field": "title", "message": "title is required" }`, where `line` is the row's one-based position in the set being validated and `field` is null for a whole-row check.

A failure returns `{ "error": "…" }`: 400 for an unreadable or undeserializable body, 405 for a method the endpoint does not take, 404 for a path under `/api/` that is no endpoint, and 500 for file and serialization trouble. A 404 elsewhere is the page that was not found and answers in plain text, since nothing under `/api/` is asking.

`GET /api/views/<view>` answers a view, taking its parameters as the query string:

```json
{ "view": "on-loan", "title": "On loan",
  "params": [ { "key": "branch", "label": "Branch", "type": "select",
                "options": [{ "value": "cen", "label": "Central" }],
                "default": "cen" } ],
  "args": { "branch": "cen" },
  "note": "2 of 3 book(s) at Central are out on loan.",
  "sections": [ { "heading": "Out", "note": "Days left before they are due.",
                  "columns": [ { "field": "title", "label": "Title", "type": "string",
                                 "href": "link", "width_ch": 30 } ],
                  "rows": [ { "title": "Nine Doors", "link": "https://…" } ] } ] }
```

There is no PUT and no derive: a view is read. A view nobody serves is a 404, and any method but GET on one is a 405. `note` is omitted where a view supplies none, as are a section's `heading` and `note`.

`GET /api/health` returns `{"status":"ok","app":"<name>"}` and `POST /api/shutdown` returns `{"status":"stopping"}` and exits. Health names the app so that a launch can tell its own server from another one on the same port. A body of `{"status":"ok"}` with no `app` is read as a table editor built before health bodies carried the name, which `stop` and `--restart` act on but a launch never adopts.

## The schema

The schema is data, not code: it carries everything the editor needs to render and validate a table. It is rebuilt on every read, so anything drawn from a sibling table—an option list, a dependent option map, a column width—is current.

```json
{
  "table": "books",
  "title": "Books",
  "sortable": true,
  "columns": [
    { "field": "title", "label": "Title", "type": "string" },
    { "field": "genre", "label": "Genre", "type": "select", "allow_empty": true,
      "options": [{ "value": "Reference" }, { "value": "Travel" }],
      "cascades_to": ["subgenre"] },
    { "field": "subgenre", "label": "Subgenre", "type": "select", "width_ch": 15,
      "options_by": { "field": "genre",
                      "options": { "Reference": [{ "value": "Natural History" }] } } },
    { "field": "copies", "label": "Copies", "type": "number", "int_only": true },
    { "field": "lent", "label": "Lent", "type": "boolean" },
    { "field": "shelved", "label": "Shelved", "type": "map",
      "key_label": "Branch", "value_label": "Count",
      "key_options": [{ "value": "hb", "label": "Harbour" }, { "value": "Central" }],
      "value_options": [{ "value": "None" }, { "value": "One" }],
      "allow_new_keys": false, "allow_new_values": false },
    { "field": "shelf", "label": "Shelf", "type": "computed", "from": "shelf" },
    { "field": "comment", "label": "Comment", "type": "text", "wide": true }
  ],
  "new_row": { "defaults": { "title": "", "genre": "" }, "carry_forward": ["genre"] },
  "datalists": { "genre-names": { "options": ["Reference", "Travel"] } }
}
```

The column types are `string`, `text`, `spaced-string`, `number`, `boolean`, `select`, `computed`, and `map`.

The sentences below say what a bundle does with each. They are the contract a bundle honours, not a description of one: a repository serving a bundle of its own through `index_html` is taking these on.

* A `number` column stores a number rather than a string. Under `int_only`, a bundle rounds what the cell is given to a whole number and steps it by one.
* A `boolean` column stores a JSON boolean. A bundle gives the cell an unset state beside true and false, and writes unset as an absent field rather than as `false`, so a row nobody has answered is told apart from one answered no.
* A `select` carries either a fixed `options` list or an `options_by` map keyed on another column's value. An option is `{ "value": …, "label": … }`, and the label is omitted where it would repeat the value; a bundle shows the label and stores the value.
* A `computed` column is read-only and takes its value from the row's derivation by `from`.
* `href` names another field of the same row holding a URL, and a bundle shows the cell as a link to it. It is honoured where a cell is read rather than edited—a `computed` column of a table, and every column of a view—and ignored elsewhere, since a cell being typed into cannot also be a link. The link opens in a tab of its own, carries `rel="noopener noreferrer"`, and is followed only when it is an absolute `http:` or `https:` URL: a field holding `javascript:` or `data:`, or a relative path, is shown as text. Relative paths are not followed because the same one would mean different things at the root and behind a reverse proxy.
* A `map` column stores a key-to-value object, and a bundle renders one chip per entry, drops an entry whose value is cleared, and writes a map that empties as an absent field. Beside the common fields it carries `key_label`, `value_label`, `key_options`, `value_options`, `allow_new_keys`, `allow_new_values`, and `chip`. Both option lists take the same `{ "value", "label"? }` shape a select's do, so a key can show a title beside the code that is stored. Under `allow_new_keys` a bundle lets a key be typed that the list does not offer, and under `allow_new_values` the value is free text with `value_options`, if any, as suggestions.
* `"chip": "key"` puts the stored key on the chip rather than the key's label, for a table whose keys are short codes standing for long titles: six chips of `Code—Long Title` make a row several lines tall, and the label is in the panel either way. It is omitted when a chip shows the label, which is what it does unless a table says otherwise.

All four of the map's option and flag fields are written every time, empty lists and `false` included, because together they are what the control is made of. A select's `options` is the exception that proves it: that one is omitted when empty, because a select with no fixed options carries `options_by` instead.

A map's entries keep the order the row carried them in, with one exception no browser can help: JavaScript orders integer-like keys — `"1"`, `"12"` — numerically and ahead of every other key, whatever the file said. A table whose entry order matters must not use keys that look like array indices.

A cell shows the first three entries and then a chip reading `+N` for however many are left, and opening it lists them all. Three is the count whatever the entries are, so a column's height is the same in every row and a table can be designed around it rather than around how long its keys happen to be. A chip too long for its own width is cut short; the panel has the whole of it.

## What `width_ch` means

`width_ch: n` means that n characters of the value fit without being cut off. A bundle adds whatever the control puts around them, so a table counts characters and nothing else:

* A text or number box adds its padding and border. Number boxes have no spinner arrows: they take two characters out of a narrow box, change the value on a stray click, and are no use in a grid that is typed into.
* A box that completes from a `datalist` adds the room a browser gives its dropdown arrow, as does a `select`, where n is about the longest option label rather than the stored value.
* A `computed` column is sized the same way and cuts longer text short with the full value in its tooltip, since a wrapped line in a dense grid pushes every other column's row apart.
* A `boolean` and a `map` ignore `width_ch`: the first is three fixed choices, and the second is chips whose width is the bundle's business.

A column that names no width gets 16 characters, or 40 where it is `wide`. Nothing about this is a browser measuring anything: the width is arithmetic on the schema, which is why a server can compute a column's width from its data and have it mean what it says.

`sortable` is a view setting only: a bundle sorts what is on screen, ascending then descending then not at all, leaves a blank cell last whichever way the column is pointed, turns row dragging off while a sort is on, and writes rows in their stored order regardless. Absent fields are omitted rather than sent as null, and `table` and `title` are stamped in by the server from the table's own `name` and `title`, so the two cannot disagree.

`new_row` says what the editor adds: the fields in `defaults`, then each field named in `carry_forward` taken from the last row that has a value for it, so a run of rows sharing a genre is typed once. `datalists` are the completion lists columns draw on: `{ "options": [ … ] }` is a list the server computed, and `{ "from_rows": { "fields": [ … ], "separator": " " } }` is built from the rows on screen by trimming each named field, dropping the row when the first is blank, joining the rest, then deduping and sorting. A `speak` column gets a button that fetches its URL with the cell's URL-encoded value in place of `{value}` and plays what comes back, so the service it names has to answer with audio a browser can play. A `localStorage` entry under `storage_key` replaces that URL's origin — scheme, host and port together — so the same bundle can be pointed at a service somewhere else without being rebuilt.

## What a write sends

A PUT sends every field of every row the server sent, including fields no column names, so a table whose rows hold more than the editor shows round trips unchanged.

A cleared cell is written as an absent field, not as an empty string, so clearing a cell leaves the stored JSONL as though the field had never been filled in. The exception is a field the schema's `new_row.defaults` gives an empty string: that is the server saying a row of this table always carries the field, and a row type with a plain `String` there could not read an absent one back. Such a field is cleared to `""` instead. The same rule clears the dependants of a `cascades_to` column when its value changes.

A cell of a `string` or `text` column counts as cleared when nothing but whitespace is left in it. A `spaced-string` counts as cleared only when it is empty, and is stored exactly as typed, because spacing is what that type is for.

A map entry the edit did not touch is written back exactly as it was read, so a value the editor shows as text but the file stores as a number stays a number. An entry that is edited follows the map it is in: where the entry's own previous value was a number, or every other value is, what is typed is stored as a number when it reads as one.

## What the editor does with the table

* Editing saves. There is no save button: a change is written a moment after it is made, and the page says when it last was.
* A save that fails is a banner that stays, naming what the server said, with the edits still on screen. It is retried on a lengthening timer as well as on the next edit, so a server that was restarted underneath the page catches up on its own. Closing the page while anything is unwritten asks first, and switching tables waits for the write before it navigates.
* Deleting a row offers an undo rather than asking first. The row comes back where it was, with everything it held, and because editing saves, the restoration saves too. The offer lasts about ten seconds or until the next edit.
* A cell holding something that is not what its column describes — a number column holding `"1994"`, a boolean holding `"true"`, a null — shows that value, marked, rather than appearing empty. Editing another cell of the row leaves it exactly as it was.
* Dragging a row onto another puts it where that row was, the same rule in both directions. Sorting or filtering turns dragging off, since a view that is not the stored order has no order to rearrange.
* Adding a row while a filter is on clears the filter, so the new row cannot be added somewhere invisible.
* The page is served from a repository's own machine and asks nothing of the network: no fonts, no analytics, nothing from a CDN. A build that introduced such a request fails the test that reads the committed page.

## Views

A table is where the writing happens. A view is where the reading happens: a read-only page the server computes and the browser renders, answering a question the tables can only be read to answer. It reuses the column schema, so the browser renders a view with what it already knows and learns nothing about what a row means.

A repository implements `ViewLogic` once per view:

```rust
impl ViewLogic for OnLoan {
    fn name(&self) -> &'static str { "on-loan" }
    fn title(&self) -> &'static str { "On loan" }

    fn params(&self, ctx: &Context, asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
        Ok(vec![Param::select("branch", "Branch", options).default("cen")])
    }

    fn render(&self, args: &ViewArgs, ctx: &Context) -> Result<ViewData, ApiError> {
        Ok(ViewData::new()
            .note("2 of 3 book(s) at Central are out on loan.")
            .section(Section::new(columns).heading("Out").rows(rows)?))
    }
}
```

`App::views` lists them, before the tables, and `App::front` says what a bare address opens: `Front::View(name)`, `Front::Table(name)`, or `Front::FirstTable`, which is what an app that says nothing gets. Both have defaults, so an app of tables alone implements neither. A view's name may not be one of the reserved names, may not be a table's, and may not be another view's; a name may hold only letters, digits, `-`, `_`, `.` and `~`, since it is a path segment and anything else would have to be escaped to be linked to; a parameter may not be keyed `view` or `table`, which are how an address says which page it is on; and a front page must name something the app serves. Building a `Server` over any of those panics, as it does for a table's name or file.

`params` and `render` both run on every request, against one `Context`, so a view reading three tables reads each of them once however many of its parts consult them. Parameters are rebuilt each time for the same reason a schema is: a select of the branches is filled from the branches there are now.

`params` is told what the address asked before anything is resolved, so one parameter's options can follow another's answer: a select of subgenres offers the ones belonging to the genre that was chosen. What it is told is the raw query string, since resolving it is what it is being consulted for.

What a view was asked for is `ViewArgs`: the address's query string, with a declared parameter the address left out filled in from its default. Everything the address carried is kept, including keys no parameter names, so a view may read more than it declares.

A select's answer has to be one of the options it offered. One that is not falls back to the default, and to nothing at all where the parameter has no default, so a stale link or a hand-edited address gets an answer the page can show rather than a question the controls cannot represent. A select with no fixed options—one whose options are computed and came back empty—takes whatever it is given. An empty answer is kept as an empty answer: a parameter the reader cleared means all of them, and is not the same as a parameter never asked about.

`Section::rows` takes anything that serialises, so a view hands over its own row type rather than building `serde_json::Value` by hand. A row that cannot be serialised is an error naming the section it was going into.

Sections carry their own columns, so two sections can differ: one listing what is overdue wants a column of how late, and one listing what is merely out does not. Sections that should line up are given the same columns.

A view is read-only in every sense: no editing, no autosave, no undo, no dragging, no sorting, and no filtering. Nothing in the editor's write machinery is reached.

## What the browser does with a view

The page is the title, the note, the parameter controls, then the sections in order, each its heading, its note, and its rows. `?view=<name>&<param>=<value>` is the whole of the question, so a page can be linked to, bookmarked, and reloaded. A section with no rows still shows its heading, and says there are none.

Changing a control rewrites the address and fetches the answer into the page that is already open. The control keeps the focus, the page does not scroll back to the top, and Back and Forward ask the previous question and the next one again. One change is one entry in the history; asking the question that is already on screen is no entry at all. A select asks as soon as it changes, and a typed parameter asks when it is left or when Enter is pressed, since each question is a fetch. While an answer is on its way, and for as long as one fails to arrive, what is on screen is dimmed: it answers the older question. A failed fetch says so above the results, with a retry, and leaves them there. A region marked `aria-live="polite"` reads out the row counts as each answer lands, since for a reader who cannot see the page the counts are what changed.

The address carries a cleared parameter as a key with an empty value, because clearing one is an answer and dropping the key would let the default back in. The view's own name is written last, so no parameter can displace it.

An empty address opens whatever `front` names, and the launcher leaves the address bare for that reason: `app web` opens the front page, `app web <name>` opens that table or view by name. An app that names no front page has no page to leave the address bare for, so `app web` opens `?table=<first table>`.

A cell is its column's type as a table would show it. `width_ch` gives it room for that many characters of text and cuts longer text short, with the whole of it in the cell's tooltip; the number counts the text alone, since a view's cell has no input around it to make room for, where a table's adds its border, padding and dropdown arrow. A column naming no width takes what its content needs, rather than the 16 or 40 characters a table's would fall back to.

At 380px the parameter controls are full width and stacked, and each section is a stack of cards, one per row: the first column is the card's title, a link where that column has an `href`, and the rest are label-and-value lines with the empty ones left out. A title too long for the card is broken across lines, including one long word with nowhere to break. Above 640px a section is a table under its heading. The page never scrolls sideways at any width; a section with more columns than fit scrolls inside its own box at the wider sizes, where there is a table to scroll.

A cell's `href` is followed only when it is an absolute `http:` or `https:` URL carrying no username or password. Anything else—another scheme, a relative or scheme-relative address, or text that is no URL—is shown as text, since a row is data from a file and a link in one must not be a way to run something by clicking a cell. A link that is followed opens in a tab of its own and tells that tab nothing about this one.

## Reserved names

A table or a view may not be named `app`, `derive`, `health`, `shutdown`, `stop`, or `views`. All but `stop` are matched before the table and view routes—`derive` because `/api/<table>/derive` is one, and `views` because `/api/views/<view>` is—and `stop` is the `stop` subcommand, which clap reads before the positional name, so such a table would be unreachable. Building a `Server` over one panics rather than serving it.

## Launching

`ServerArgs` carries the arguments the editor's subcommand takes. A repository whose subcommand takes arguments of its own flattens `ServerArgs` into its own `Args` struct and adds them alongside.

Launching reuses this app's server already running on the port, which is why running the command twice opens a second browser window rather than a second server; `--restart` shuts the old one down first. A port held by another app's editor, or by anything else, is an error naming both apps rather than a reuse, and `stop` leaves such a server running. The server itself runs as a detached worker process, so the command that starts it returns at once, and it holds none of that command's standard handles: piping the launch into something — `app web | tail`, a script, a CI step — reaches the end of the output as soon as the launch is done rather than when the server eventually stops. The worker is marked by an environment variable, which is how it knows to bind rather than spawn another copy of itself. `--api-only` skips all of that and serves the API in the foreground, for running the UI from Vite.

A server answering exactly `{"status":"ok"}` is read as a table editor built before health bodies named the app. It is never reused, because there is no telling whose tables it would serve, but `stop` takes it down and `--restart` replaces it, so upgrading a consumer never leaves a server on the port that the new binary can neither stop nor take the port from. Both say in so many words what they are doing.

That rule is exact, and deliberately so. `{"status":"ok"}` means an object with the one key `status` holding the string `ok`. A body with any further key, an `app` that is not a string, or a status that is not `ok` belongs to something else with a health endpoint of its own—`{"status":"ok","service":"metrics"}` is a different program—and the editor neither adopts it, shuts it down, nor calls it a table editor: it reports a port serving something that is not one, and says to pass `--port`.

The `Server` builder holds what differs between repositories:

* `index_html` serves a bundle of the repository's own in place of the embedded one. Such a bundle takes on everything the server states but does not enforce: reading the address, and resolving a bare one through `front` to a view, a table, or the first table; the schema, meaning every column type and modifier and what `width_ch` counts; the write rules, meaning what a cleared cell writes and what a `cascades_to` change clears; and asking nothing of the network.
* `child_env` names the worker marker. A repository whose server is registered as a system service keeps its own name here, so the service entry does not have to change.
* `command` names the subcommand that reaches `run`, used when re-invoking the binary as a worker. It defaults to `web`.
* `default_port` is the port bound when `--port` names none. Each app takes its own, so two editors on one machine do not land on the same port. It defaults to 8787.
* `worker_args` are arguments forwarded to the detached worker after the table and the port. The worker is a fresh invocation of the binary and is given only those two, so a flag the user passed the parent does not reach it; a flag the serving process needs goes here. The worker inherits the environment regardless, so a setting that already lives in a variable needs no forwarding. Each forwarded argument has to be one the editor's subcommand declares, has to be a flag rather than a positional—the table is the only positional that command line has, and a forwarded positional is refused outright—and must not repeat `--port` or the table. An argument that breaks the last two rules leaves the worker unable to parse its own command line; the launch then fails at once with what the worker wrote to stderr, rather than waiting out the start-up window.
* `before_launch` runs once in the process the user invoked, before a server is started or reused—bringing up a companion service, say. It does not run in the worker.

## Probing a local server

`probe` and `probe_status` ask a server on `127.0.0.1` a question and hand back what it said:

```rust
use std::time::Duration;

let up = table_editor::probe_status(8765, "GET", "/health", Duration::from_millis(300));
let (status, body) = table_editor::probe(8787, "GET", "/api/health", Duration::from_millis(500))
    .expect("a server on the port");
```

They are the same one-shot exchanges the launcher uses on its own health and shutdown endpoints, exported because a repository whose editor brings up a companion service needs the same question answered about it. `None` means nothing accepted a connection, and a status of `0` means something answered but not in HTTP this could read, which still says something is there. The timeout bounds the connection and each read and write separately, not the call as a whole.

This speaks plain HTTP to a local server: no TLS, no redirects, no chunked decoding, and no header parsing beyond the status line. It is not a general-purpose HTTP client.

## The browser bundle

`Web/` holds the bundle's sources: React and Tailwind, built by Vite into one self-contained page with the script and the styles inlined. `assets/index.html` is what that build writes. It is a build artefact, it is not in the repository, and the build is the only thing that writes it: run `./Deploy.ps1` from the repository root, which installs the dependencies and builds. Never hand-edit it.

The published crate carries that page, built at release. A consumer therefore needs no bun, no Node, and no network: `cargo build` gives them the editor.

A checkout, though, may not have the page at all, and Rust work must not wait on a JavaScript toolchain. So `build.rs` copies whichever page is there into `OUT_DIR` and the crate includes it from there: the built bundle where there is one, and `assets/placeholder.html`—a page that says the bundle has not been built and how to build it—where there is not. The placeholder path prints a `cargo:warning`, so it is never silent. `cargo check`, clippy and the whole test suite pass either way, and the crate's own test holds the embedded page to the standard for whichever of the two it is.

A release never ships the placeholder. `./Release.ps1` builds the bundle, packages the crate, and reads the page out of the package that is about to be uploaded, refusing to go on unless it is the built editor: a doctype, the element the editor mounts on, no dev-server script tag, over 50 KB, no carriage return, and nothing fetched from the network. CI runs that same script on every change, without the switch that uploads, so the release path is exercised continuously rather than once a year.

## Developing the bundle

`examples/library` is a consumer to develop against: an app called Library with three tables carrying every column type and modifier between them, a view over those tables with two sections, notes, a link column, and four parameters—a select, a pair where the second's options follow the first's answer, and a typed one—and invented data in `examples/library/Data`. It binds 8791, which is picked to stay out of the way of a real editor on the same machine—each app takes a port of its own, and a launch refuses a port another app is serving rather than taking it.

Two of its books are there to be looked at rather than read: one whose title is a single unbroken 61-character word, and one whose link is a `javascript:` URL. They are what the claims about a wrapped title and a refused link are checked against, so a change to either rule shows up by opening the example at a narrow width.

```
cargo run --example library -- web --api-only    # the API on 127.0.0.1:8791
bun run --cwd Web dev                            # Vite on 5173, proxying /api to 8791
```

Vite serves the UI with hot reloading and proxies `/api` to the example, so the editor is exercised against a real server. `cargo run --example library -- web` instead serves the embedded page and opens a browser on it, which is how to check what a consumer will actually get; `cargo run --example library -- web stop` shuts that one down. The page is embedded at build time, so a server started that way serves whatever the binary was built from: build the bundle, then the example, before looking at a change through it. In a checkout that has never built the bundle, what it serves is the placeholder, which says so.

The example writes to its own `Data/*.jsonl`, so edits made while developing show up as changes to those files. They are committed, and reverting them is how to get back to the data the example ships with.

## Versions

The crate is published to [crates.io](https://crates.io/crates/table-editor). Versions are semver, with one thing worth saying plainly: the JSON the server sends—the column schema, the view payload, the write format—is a contract between the crate and the page it ships, and the two always ship together. A consumer cannot mix a schema from one version with a page from another, so a change to that JSON is not a breaking change for a consumer the way a change to the Rust API is. What breaks a consumer is the Rust they compile against: the traits, their method signatures, the builder, the types re-exported from `lib.rs`.

So, before 1.0, each `0.x` is a compatibility line for the Rust API. A release that changes a trait, removes a method, or changes what an existing method means bumps the minor version. A release that adds a defaulted trait method, a builder, a column type, or a schema field bumps the patch version, as does one that only changes the page. After 1.0 the ordinary rules apply, with the wire format still understood as internal to the pair.

`0.1.0` is the first published version.

A published version is permanent. crates.io allows a version to be yanked, which stops new resolution picking it up, but never replaced and never deleted, and anything already depending on it keeps working. A mistake is fixed by publishing the next version, not by editing this one.

## Releasing

Run from the repository root, on `main`, with a clean tree:

- Bump `version` in `Cargo.toml`, and update the version in this README's dependency examples. Commit that on its own.
- `./Release.ps1` — builds the bundle, packages, checks that the packaged page is the built editor, and dry-runs the publish. It changes nothing outside `target/`.
- Read what it packaged: `cargo package --list`, and the size it reports. Two warnings about `tests/api.rs` and `tests/stop.rs` not being included are expected; the integration tests are not published.
- `./Release.ps1 -Publish` — the same, and then uploads. It refuses on a dirty tree and refuses if the packaged page is the placeholder. This step is irreversible.
- `git tag v<version> && git push origin v<version>`.

Publishing needs a crates.io token: create one at [crates.io/settings/tokens](https://crates.io/settings/tokens) with the publish scope, then `cargo login`. The token is stored by cargo, not by this repository.

## Commands

- `cargo build` — build the crate
- `cargo fmt --all` — format
- `cargo fmt --all -- --check` — format check (CI gate)
- `cargo clippy --all-targets -- -D warnings` — lint (CI gate)
- `cargo test --all-targets` — run tests
- `cargo test --doc` — run the doctests, which `--all-targets` leaves out (CI gate)
- `cargo clippy --no-default-features --all-targets -- -D warnings` — lint the file-format-only build (CI gate)
- `cargo test --no-default-features` — test the file-format-only build (CI gate)
- `bun install --cwd Web` — install the bundle's dependencies
- `bun test --cwd Web` — test the bundle's helpers (CI gate)
- `bun run --cwd Web check` — type-check the bundle
- `bun run --cwd Web build` — type-check and build, writing `assets/index.html` (CI gate)
- `./Deploy.ps1` — install and build in one step
- `./Release.ps1` — build, package, check the packaged page, and dry-run the publish (CI gate)
- `./Release.ps1 -Publish` — the same, and then publish to crates.io

## Licence

MIT. See [LICENSE](https://github.com/RKeelan/TableEditor/blob/main/LICENSE).