@orkestrel/scaffold 0.0.67 → 0.0.68
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/dist/bin/main.js +67 -44
- package/dist/bin/main.js.map +1 -1
- package/dist/host/agents/templates/brief.md +9 -0
- package/dist/host/claude/agents/orkestrel.md +4 -4
- package/dist/host/claude/rules/names.md +15 -0
- package/dist/host/claude/rules/tests.md +33 -4
- package/dist/host/claude/rules/workspace.md +14 -2
- package/dist/host/dotfiles/prettierignore +3 -0
- package/dist/host/guides/README.md +65 -0
- package/dist/host/guides/abort.md +169 -0
- package/dist/host/guides/agent.md +1509 -0
- package/dist/host/guides/brief.md +1266 -0
- package/dist/host/guides/browser.md +2200 -0
- package/dist/host/guides/budget.md +196 -0
- package/dist/host/guides/codec.md +519 -0
- package/dist/host/guides/console.md +785 -0
- package/dist/host/guides/contract.md +1193 -0
- package/dist/host/guides/csv.md +541 -0
- package/dist/host/guides/database.md +2518 -0
- package/dist/host/guides/emitter.md +233 -0
- package/dist/host/guides/form.md +1791 -0
- package/dist/host/guides/html.md +717 -0
- package/dist/host/guides/indexeddb.md +505 -0
- package/dist/host/guides/interpret.md +1029 -0
- package/dist/host/guides/lsp.md +515 -0
- package/dist/host/guides/markdown.md +964 -0
- package/dist/host/guides/mcp.md +5554 -0
- package/dist/host/guides/middleware.md +927 -0
- package/dist/host/guides/msg.md +440 -0
- package/dist/host/guides/ndjson.md +120 -0
- package/dist/host/guides/ollama.md +380 -0
- package/dist/host/guides/pool.md +280 -0
- package/dist/host/guides/probe.md +1210 -0
- package/dist/host/guides/process.md +1620 -0
- package/dist/host/guides/program.md +1110 -0
- package/dist/host/guides/qualifier.md +854 -0
- package/dist/host/guides/queue.md +370 -0
- package/dist/host/guides/rater.md +330 -0
- package/dist/host/guides/reason.md +1122 -0
- package/dist/host/guides/relation.md +373 -0
- package/dist/host/guides/router.md +753 -0
- package/dist/host/guides/scaffold.md +192 -31
- package/dist/host/guides/sea.md +383 -0
- package/dist/host/guides/server.md +752 -0
- package/dist/host/guides/sqlite.md +330 -0
- package/dist/host/guides/sse.md +187 -0
- package/dist/host/guides/supervisor.md +4890 -0
- package/dist/host/guides/table.md +1556 -0
- package/dist/host/guides/template.md +280 -0
- package/dist/host/guides/terminal.md +1145 -0
- package/dist/host/guides/test.md +2969 -0
- package/dist/host/guides/timeout.md +252 -0
- package/dist/host/guides/tool.md +311 -0
- package/dist/host/guides/toolbox.md +1038 -0
- package/dist/host/guides/websocket.md +282 -0
- package/dist/host/guides/worker.md +615 -0
- package/dist/host/guides/workflow.md +1507 -0
- package/dist/host/guides/workspace.md +595 -0
- package/dist/host/manifest.json +1218 -10
- package/dist/host/tests/policy.test.ts +279 -2
- package/dist/host/tests/setupPolicy.ts +437 -6
- package/dist/src/core/index.cjs +38 -16
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +33 -9
- package/dist/src/core/index.d.ts +33 -9
- package/dist/src/core/index.js +37 -17
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1750 -1567
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +106 -24
- package/dist/src/server/index.d.ts +106 -24
- package/dist/src/server/index.js +1751 -1570
- package/dist/src/server/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,1556 @@
|
|
|
1
|
+
# Table
|
|
2
|
+
|
|
3
|
+
> The environment-agnostic tabular document: a `TableSchema` declaring the columns, a `Table`
|
|
4
|
+
> holding the rows given against it, and one lens of sort, filter, and page deciding which of them
|
|
5
|
+
> the view shows.
|
|
6
|
+
|
|
7
|
+
Nothing here renders, measures a pixel, reads a keyboard, or names a host type. **A grid, a report,
|
|
8
|
+
a terminal listing, and a CSV export are the same abstraction.** They all hold a set of records,
|
|
9
|
+
order them, narrow them, and show a stretch of them. What differs is who draws the result, and
|
|
10
|
+
drawing is the one part this package leaves out. The table owns values; the host owns everything a
|
|
11
|
+
person looks at.
|
|
12
|
+
|
|
13
|
+
The row store is the source of truth, and it is the whole of it. Sort terms, filters, the picked
|
|
14
|
+
keys, the opened keys, and the page are held; `view` and every tally are worked out on read, so no
|
|
15
|
+
second copy of an answer can go stale. A write validates all of itself before any of it lands, and
|
|
16
|
+
announces itself once it has.
|
|
17
|
+
|
|
18
|
+
The core refuses rather than throws. Every guard returns `false` off-shape rather than throwing,
|
|
19
|
+
every parser returns `undefined` on refusal, and every row the table hands back is a frozen owned
|
|
20
|
+
copy. Table-owned refusals raise `TableError`, and each one names a caller mistake.
|
|
21
|
+
|
|
22
|
+
## Surface
|
|
23
|
+
|
|
24
|
+
Everything in this guide is exported from `@orkestrel/table` ([`src/core`](../src/core)). The manager
|
|
25
|
+
classes and the key-set shell they compose are the module's internal declarations: they stay out
|
|
26
|
+
of the barrel, so no consumer can construct one, and each constructor takes the table's emitter
|
|
27
|
+
and closures over state `Table` keeps private, which is why they are internal rather than
|
|
28
|
+
published. Every other declaration is reachable from the barrel, so a consumer holds the
|
|
29
|
+
mechanisms the package uses on itself.
|
|
30
|
+
|
|
31
|
+
### Open a table
|
|
32
|
+
|
|
33
|
+
Open a table, narrow it, order it, and read the rows to draw:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { createTable } from '@orkestrel/table'
|
|
37
|
+
|
|
38
|
+
const table = createTable(
|
|
39
|
+
{
|
|
40
|
+
label: 'People',
|
|
41
|
+
key: 'id',
|
|
42
|
+
columns: [
|
|
43
|
+
{ cell: 'text', key: 'id', label: 'Reference' },
|
|
44
|
+
{ cell: 'text', key: 'name', label: 'Name' },
|
|
45
|
+
{ cell: 'number', key: 'age', label: 'Age' },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
rows: [
|
|
50
|
+
{ id: '1', name: 'Ada', age: 36 },
|
|
51
|
+
{ id: '2', name: 'Grace', age: 45 },
|
|
52
|
+
{ id: '3', name: 'Alan', age: 41 },
|
|
53
|
+
],
|
|
54
|
+
limit: 2,
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
table.filter.set({ column: 'name', operator: 'contains', text: 'a' })
|
|
59
|
+
table.sort.set({ column: 'age', direction: 'descending' })
|
|
60
|
+
|
|
61
|
+
table.count // 3 — every name holds a lowercase 'a'
|
|
62
|
+
table.pagination.count // 2 — two pages of two
|
|
63
|
+
table.view.map((row) => row.name) // ['Grace', 'Alan'] — page one, oldest first
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Rows, cells, and columns
|
|
67
|
+
|
|
68
|
+
The document itself — what a table declares and what one row of it holds. All data, no behavior.
|
|
69
|
+
|
|
70
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. An extended interface's name comes before `plus`, with the members it adds after.
|
|
71
|
+
|
|
72
|
+
| API | Kind | Shape | Summary |
|
|
73
|
+
| -------------- | --------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
74
|
+
| `TableKey` | type | `string` | Represents a row's identity — a `string`, carried in the cell the schema's `key` names. |
|
|
75
|
+
| `TableCell` | type | `string \| number \| boolean` | Represents every value a cell can hold — a `string`, a `number`, or a `boolean`. |
|
|
76
|
+
| `TableRow` | type | `Readonly<Record<string, TableCell>>` | Represents one row, keyed by column. A column nobody has filled has no key here. |
|
|
77
|
+
| `ColumnCell` | type | `'text' \| 'number' \| 'flag' \| 'choice'` | Names what a column's cells hold — the discriminant that fixes the column's options, its comparison, and the filters that apply to it. |
|
|
78
|
+
| `ColumnChoice` | interface | `{ value, label, help? }` | Represents one value a `choice` column offers — `value` is stored, `label` is read, and `help` explains. |
|
|
79
|
+
| `ColumnBase` | interface | `{ key, label?, help?, hidden?, meta? }` | Describes what every column carries, whatever its cells hold — the name a row's cell uses, the text a reader sees, whether a host draws the column, and the metadata a host attaches to it. |
|
|
80
|
+
| `TextColumn` | interface | `ColumnBase plus { cell }` | Represents a column of text, compared lexically. Carries a date, a time, and a timestamp as ISO strings. |
|
|
81
|
+
| `NumberColumn` | interface | `ColumnBase plus { cell }` | Represents a column of numbers, compared by magnitude. |
|
|
82
|
+
| `FlagColumn` | interface | `ColumnBase plus { cell }` | Represents a column of yes-or-no answers, compared false before true. |
|
|
83
|
+
| `ChoiceColumn` | interface | `ColumnBase plus { cell, choices }` | Represents a column drawn from a declared list, compared by the order that list declares. It requires `choices`. |
|
|
84
|
+
| `TableColumn` | type | `TextColumn \| NumberColumn \| FlagColumn \| ChoiceColumn` | Represents any column a schema can declare — the union discriminated on `cell`. |
|
|
85
|
+
| `TableSchema` | interface | `{ name?, label?, help?, key, columns }` | Holds everything a table declares about itself — how the table is described, which column carries row identity, and the columns it declares, in the order it declares them. |
|
|
86
|
+
|
|
87
|
+
### The lens
|
|
88
|
+
|
|
89
|
+
The axes a table reads its rows through, and the slots that replace what a column's cell fixes.
|
|
90
|
+
|
|
91
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`.
|
|
92
|
+
|
|
93
|
+
| API | Kind | Shape | Summary |
|
|
94
|
+
| ---------------- | --------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
95
|
+
| `TableTerm` | interface | `{ column }` | Represents one entry of a lens list — the `column` it names. A sort term and a filter each hold one, which is why the two share a list engine. |
|
|
96
|
+
| `TableDirection` | type | `'ascending' \| 'descending'` | Names which way a column sorts. A column nobody has sorted carries no term at all. |
|
|
97
|
+
| `TableOrder` | interface | `{ column, direction }` | Represents one column's place in the sort — the `column` and its `direction`. The list is read left to right. |
|
|
98
|
+
| `FilterOperator` | type | `'contains' \| 'between' \| 'equals'` | Names how a filter tests a cell — the operator each filter carries. |
|
|
99
|
+
| `ContainsFilter` | interface | `{ column, operator, text }` | Keeps the rows whose cell holds this `text` somewhere inside it. |
|
|
100
|
+
| `BetweenFilter` | interface | `{ column, operator, minimum, maximum }` | Keeps the rows whose cell falls between `minimum` and `maximum`, both included, compared the way the column compares. |
|
|
101
|
+
| `EqualsFilter` | interface | `{ column, operator, value }` | Keeps the rows whose cell holds exactly this `value`. |
|
|
102
|
+
| `TableFilter` | type | `ContainsFilter \| BetweenFilter \| EqualsFilter` | Represents any filter a table can hold — the union discriminated on `operator`. |
|
|
103
|
+
| `CellComparator` | type | `(left: TableCell \| undefined, right: TableCell \| undefined) => number` | Compares two cells of one column, replacing what its `cell` fixes. Always describes ascending order; direction is applied afterwards. |
|
|
104
|
+
| `CellMatcher` | type | `(cell: TableCell \| undefined, filter: TableFilter) => boolean` | Tests one column's cell against a filter, replacing what its `cell` fixes. Receives every filter the table holds against that column. |
|
|
105
|
+
|
|
106
|
+
### The table
|
|
107
|
+
|
|
108
|
+
The entity, its managers, its factory, its contract, and the error it raises.
|
|
109
|
+
|
|
110
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. A function row's `Shape` cell holds its signature, and a guard row's the type it narrows to. A class row's `Shape` cell holds the interface it implements, or its constructor signature where it implements none.
|
|
111
|
+
|
|
112
|
+
| API | Kind | Shape | Summary |
|
|
113
|
+
| ---------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
114
|
+
| `Table` | class | `TableInterface` | Holds a schema, its rows, and the lens through which they are read, implementing `TableInterface` exactly. |
|
|
115
|
+
| `TableInterface` | interface | `{ emitter, schema, rows, sort, filter, selection, expansion, pagination, view, count, destroyed } plus clear, destroy` | Represents a table: what it declares, the rows it holds, and the lens it reads them through. |
|
|
116
|
+
| `createTable` | function | `(schema: TableSchema, options?: TableOptions) => TableInterface` | Opens a table against a schema. The schema is copied, and the copy is what the table declares. |
|
|
117
|
+
| `TableOptions` | interface | `{ on?, error?, rows?, comparators?, matchers?, limit? }` | Describes how to open a table — the listeners wired at construction and where a throw from one goes, the rows seeded into it, the per-column comparison and test replacements, and the page size. |
|
|
118
|
+
| `TableEventMap` | type | `{ write, remove, sort, filter, select, expand, paginate, clear }` | Lists everything a table announces, mapping each event to the payload its listeners receive. |
|
|
119
|
+
| `RowManagerInterface` | interface | `{} plus row, rows, add, update, move, remove` | Manages the rows a table holds, in the order it holds them. |
|
|
120
|
+
| `SortManagerInterface` | interface | `{} plus order, orders, set, remove` | Manages the order a table reads its rows in. |
|
|
121
|
+
| `FilterManagerInterface` | interface | `{} plus filter, filters, set, remove` | Manages which rows a table keeps. |
|
|
122
|
+
| `SelectionManagerInterface` | interface | `{ keys } plus select, clear, toggle` | Manages the rows somebody has picked. |
|
|
123
|
+
| `ExpansionManagerInterface` | interface | `{ keys } plus expand, clear, toggle` | Manages the rows somebody has opened up. |
|
|
124
|
+
| `PaginationManagerInterface` | interface | `{ page, limit, offset, count } plus move, resize` | Manages which stretch of the filtered rows the view shows. |
|
|
125
|
+
| `TableError` | class | `new (code: TableErrorCode, message: string, context?: JSONRecord) => TableError` | Represents an error raised by the table domain — a machine-readable `code` and optional structured `context`. |
|
|
126
|
+
| `TableErrorCode` | type | `'SCHEMA' \| 'COLUMN' \| 'KEY' \| 'CELL' \| 'DESTROYED'` | Names the reason a `TableError` carries — the machine-readable code a `catch` branches on. |
|
|
127
|
+
| `isTableError` | function | `TableError` | Determines whether a caught value is a table error, so a `catch` branches on `code` without an assertion. |
|
|
128
|
+
|
|
129
|
+
`TableInterface`'s readonly data members stay here, in its `Shape` cell, rather than in
|
|
130
|
+
`## Methods`, and each manager's own readonly members sit in that manager's cell. Every
|
|
131
|
+
call-signature member is documented under [Methods](#methods).
|
|
132
|
+
|
|
133
|
+
The members spelled `count` answer different questions, because each is the lone tally of the
|
|
134
|
+
entity it belongs to. `table.count` is **rows** — how many the filter admits, before the page narrows them.
|
|
135
|
+
`table.pagination.count` is **pages** — how many the admitted rows fill.
|
|
136
|
+
|
|
137
|
+
Each manager class is constructed by `Table` and stays out of the barrel, so no consumer can
|
|
138
|
+
construct one. Its constructor takes the table's emitter and a set of closures over state the
|
|
139
|
+
owning table keeps private, which is what keeps every store single-owned, so the class is internal
|
|
140
|
+
and its interface is the published contract. A consumer writing its own table composes `Table`, or writes
|
|
141
|
+
its own managers against the manager interfaces and reads these classes as the working reference.
|
|
142
|
+
|
|
143
|
+
### Constants
|
|
144
|
+
|
|
145
|
+
The cell registry and the budgets. The registry is frozen, so a shared list cannot be rewritten
|
|
146
|
+
under a consumer.
|
|
147
|
+
|
|
148
|
+
A `Shape` cell holds the constant's declared type.
|
|
149
|
+
|
|
150
|
+
| API | Kind | Shape | Summary |
|
|
151
|
+
| -------------- | ----- | ----------------------- | --------------------------------------------------------------------------------------------- |
|
|
152
|
+
| `COLUMN_CELLS` | const | `readonly ColumnCell[]` | Lists every column cell, in the order declared by the public contract. |
|
|
153
|
+
| `COLUMN_LIMIT` | const | `number` | Names the maximum number of columns one schema may declare: 256. |
|
|
154
|
+
| `CHOICE_LIMIT` | const | `number` | Names the maximum number of choices one `choice` column may offer: 1024. |
|
|
155
|
+
| `NAME_LIMIT` | const | `number` | Names the maximum length of a schema name or column key: 128 UTF-16 code units. |
|
|
156
|
+
| `STRING_LIMIT` | const | `number` | Names the maximum length of any single retained string: 65536 UTF-16 code units. |
|
|
157
|
+
| `TEXT_LIMIT` | const | `number` | Names the maximum total length of every string one schema retains: 1048576 UTF-16 code units. |
|
|
158
|
+
| `NODE_LIMIT` | const | `number` | Names the maximum total number of records, arrays, and leaves one schema retains: 16384. |
|
|
159
|
+
|
|
160
|
+
### Guards
|
|
161
|
+
|
|
162
|
+
Total `is*` guards over unknown input. None throws, none coerces, and each returns `false` for
|
|
163
|
+
anything off-shape — including a hostile prototype, a symbol key, or a cyclic value.
|
|
164
|
+
|
|
165
|
+
In a guard table a `Shape` cell holds the type the guard narrows to.
|
|
166
|
+
|
|
167
|
+
| API | Kind | Shape | Summary |
|
|
168
|
+
| ------------------------- | -------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
169
|
+
| `isTableCell` | function | `TableCell` | Determines whether an unknown value has a table cell shape — a string, a finite number, or a boolean. |
|
|
170
|
+
| `isTableRow` | function | `TableRow` | Determines whether an unknown value is a record whose every own key is a string and every value a `TableCell`. |
|
|
171
|
+
| `isColumnCell` | function | `ColumnCell` | Determines whether an unknown value is a declared column cell. |
|
|
172
|
+
| `isColumnChoice` | function | `ColumnChoice` | Determines whether an unknown value is one exact `ColumnChoice` record; an unknown member refuses it. |
|
|
173
|
+
| `isTableColumn` | function | `TableColumn` | Determines whether an unknown value is one exact discriminated `TableColumn`, checked against its cell's own options. |
|
|
174
|
+
| `isStructuralTableSchema` | function | `TableSchema` | Determines whether an unknown value has the exact shape of a `TableSchema` — the shape alone, with no domain check. |
|
|
175
|
+
| `isTableSchema` | function | `TableSchema` | Determines whether an unknown value is a `TableSchema` a table can be opened against — the exact shape, and an audit that finds nothing. |
|
|
176
|
+
|
|
177
|
+
The schema guards answer about a schema, and which one to reach for is which question you are asking.
|
|
178
|
+
`isStructuralTableSchema` asks whether the shape is exact: every declared member present and typed,
|
|
179
|
+
and nothing else there. `isTableSchema` asks that and then asks `auditTable`, so it refuses a
|
|
180
|
+
schema-shaped value carrying a domain fault or a budget breach — a `key` naming no declared column,
|
|
181
|
+
a column key declared twice, a `choice` column offering nothing. It is the guard the parsers read.
|
|
182
|
+
The `Table` constructor asks in a different order. It guards the value it was handed, owns a copy
|
|
183
|
+
of it, then guards and audits that copy and keeps that same object. Its `SCHEMA` message carries
|
|
184
|
+
the audit diagnostics when the owned copy reaches the audit, and names
|
|
185
|
+
`The schema is not a table schema` when the copy fails the guard the handed value passed. It also
|
|
186
|
+
names `column "<key>" has metadata that cannot be owned` when the column's `meta` answers the
|
|
187
|
+
guard's read and the clone's read differently, the message `cloneSchema` raises and the
|
|
188
|
+
constructor rethrows unchanged.
|
|
189
|
+
|
|
190
|
+
`isTableSchema`, the constructor, and `parseTable` agree on every schema whose reads are stable,
|
|
191
|
+
which is every schema made of ordinary declared data. They part where a foreign object answers a
|
|
192
|
+
property read with something other than the value a clone of it holds — a `meta` behind a `get`
|
|
193
|
+
trap is the shipped case. `isTableSchema` admits such a schema, because it reads it once and the
|
|
194
|
+
read answers. The constructor refuses it, because it guards the copy it owns rather than the object
|
|
195
|
+
it read, and `parseTable` refuses it too, because it re-guards its own projection. Reach for the
|
|
196
|
+
structural guard where you mean to run the audit yourself and read its diagnostics.
|
|
197
|
+
|
|
198
|
+
### Helpers
|
|
199
|
+
|
|
200
|
+
The pure leaves the table composes: the column lookup, the identity read, the key-set engine, the
|
|
201
|
+
lens-list operations, the cell gate, the comparison, the filter tests, the row passes, the
|
|
202
|
+
audit, and the wire projections. `computeKeys`, `matchesTerms`, `filterRows`, and `sortRows`
|
|
203
|
+
propagate exceptions from supplied callbacks; `serializeTable` raises `SCHEMA` for a `meta` no clone
|
|
204
|
+
can own. The other helpers are total over ordinary declared inputs, subject to the core's
|
|
205
|
+
hostile-reflection boundary described later.
|
|
206
|
+
|
|
207
|
+
| API | Kind | Summary |
|
|
208
|
+
| ---------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
209
|
+
| `extractColumn` | function | Finds one column by key; `undefined` when the schema declares no such column. |
|
|
210
|
+
| `extractKey` | function | Reads one row's declared identity; `undefined` when its key cell is missing, empty, or not a string. |
|
|
211
|
+
| `computeKeys` | function | Computes one atomic 0/1/N membership change over the keys a caller may address — the engine selection and expansion share. |
|
|
212
|
+
| `mergeTerms` | function | Merges lens terms into a column-keyed list, replacing the entry that names the same column — the `set` write. |
|
|
213
|
+
| `removeTerms` | function | Removes every lens term naming one of the given columns — the drop `sort.remove` and `filter.remove` share. |
|
|
214
|
+
| `matchesTerms` | function | Checks whether two lens lists hold the same terms in the same order, with the supplied test deciding the operands. |
|
|
215
|
+
| `matchesCell` | function | Checks whether one column can hold a value — the shape gate every write and every seed passes through. |
|
|
216
|
+
| `compareCells` | function | Compares two of one column's cells the way its `cell` fixes, describing ascending order. |
|
|
217
|
+
| `admitsFilter` | function | Checks whether one column admits a filter and every operand it carries — the gate `filter.set` and `matchesFilter` share. |
|
|
218
|
+
| `matchesFilter` | function | Tests one of a column's cells against one filter the way its `cell` fixes. |
|
|
219
|
+
| `filterRows` | function | Keeps the rows every filter accepts, in the order given; a supplied `CellMatcher` replaces the default per column. |
|
|
220
|
+
| `sortRows` | function | Orders rows by the terms given, stably; a supplied `CellComparator` replaces the default per column. |
|
|
221
|
+
| `auditTable` | function | Audits a structurally valid schema for domain faults and budget breaches, returning human diagnostics. |
|
|
222
|
+
| `serializeTable` | function | Projects a schema into JSON in declaration order, dropping every absent member; raises `SCHEMA` for a `meta` it cannot own. |
|
|
223
|
+
| `serializeRows` | function | Projects rows into JSON with each row's cells in the schema's column order, dropping every absent cell. |
|
|
224
|
+
|
|
225
|
+
### Cloners
|
|
226
|
+
|
|
227
|
+
Owned frozen snapshots. The table takes one of the schema at construction and one of every row at
|
|
228
|
+
admission, so a later edit to the object a caller passed changes nothing inside the table, and no
|
|
229
|
+
row the table hands back is a live internal reference.
|
|
230
|
+
|
|
231
|
+
| API | Kind | Summary |
|
|
232
|
+
| ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
233
|
+
| `cloneRow` | function | Clones one row into an owned frozen snapshot. |
|
|
234
|
+
| `cloneSchema` | function | Clones a whole schema into an owned frozen snapshot, freezing every nested column, choice list, choice, and `meta`; raises `SCHEMA` for a `meta` it cannot own. |
|
|
235
|
+
|
|
236
|
+
`cloneRow` cannot fail for an ordinary row record, subject to the core's hostile-reflection boundary
|
|
237
|
+
described later. `cloneSchema` can also fail when a column's `meta` holds something no clone can own, such as a
|
|
238
|
+
record that refers back to itself. `meta` is typed as JSON and a cycle satisfies that type, so the
|
|
239
|
+
refusal is a `TableError` coded `SCHEMA` rather than a silent partial copy. `createTable` never reaches
|
|
240
|
+
it for a schema whose reads are stable, because `isTableColumn` admits only bounded, exactly ownable
|
|
241
|
+
JSON there and refuses such a schema first; a `meta` that answers the guard's read with ownable JSON
|
|
242
|
+
and the clone's read with something no clone can own reaches it, and it refuses
|
|
243
|
+
with `column "<key>" has metadata that cannot be owned`. This is the door a caller cloning or
|
|
244
|
+
serializing a schema on its own meets, and `serializeTable` refuses the same value the same way.
|
|
245
|
+
|
|
246
|
+
### Parsers
|
|
247
|
+
|
|
248
|
+
The wire boundary. Each returns `undefined` on refusal rather than throwing, and each returns an
|
|
249
|
+
owned value rather than the caller's.
|
|
250
|
+
|
|
251
|
+
| API | Kind | Summary |
|
|
252
|
+
| ------------ | -------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
253
|
+
| `parseTable` | function | Parses unknown wire data into an owned, structurally valid, semantically sound table schema. |
|
|
254
|
+
| `parseRows` | function | Parses unknown wire data into owned rows against one table schema, coercing a numeric string and `'true'` / `'false'`. |
|
|
255
|
+
|
|
256
|
+
**Hostile reflection.** A TypeScript shape does not guarantee that reflection succeeds. An ordinary
|
|
257
|
+
helper or cloner may propagate a throw from a proxy trap or accessor. Guards and parsers contain that
|
|
258
|
+
throw and refuse instead. This is the hostile-reflection boundary for the whole core.
|
|
259
|
+
|
|
260
|
+
## Cells
|
|
261
|
+
|
|
262
|
+
Each cell fixes what the column's cells may hold, how two of them compare, and which filter operators
|
|
263
|
+
apply. Choosing the cell is the whole of a column's behavior, which is why there is no `sortable`, no
|
|
264
|
+
`filterable`, and no comparison declared beside it.
|
|
265
|
+
|
|
266
|
+
| Cell | Holds | Its own options | Compares by | Filters with |
|
|
267
|
+
| -------- | --------- | --------------- | ---------------------------- | ------------------------------- |
|
|
268
|
+
| `text` | `string` | — | Lexical order | `contains`, `between`, `equals` |
|
|
269
|
+
| `number` | `number` | — | Magnitude | `between`, `equals` |
|
|
270
|
+
| `flag` | `boolean` | — | False before true | `equals` |
|
|
271
|
+
| `choice` | `string` | `choices` | The order `choices` declares | `contains`, `equals` |
|
|
272
|
+
|
|
273
|
+
A cell nobody has filled has no key in its row. Absence is `undefined` and never `null`, never an
|
|
274
|
+
empty string, and never a zero. An absent cell sorts before every present one in ascending order,
|
|
275
|
+
and every filter refuses it — a row with no cell in a filtered column is not a row that column
|
|
276
|
+
accepts.
|
|
277
|
+
|
|
278
|
+
### text
|
|
279
|
+
|
|
280
|
+
This fence declares a `text` column.
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
import type { TextColumn } from '@orkestrel/table'
|
|
284
|
+
|
|
285
|
+
const name: TextColumn = { cell: 'text', key: 'name', label: 'Name' }
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`contains` is the operator a filter bar reaches for, and it compares **case-sensitively**. Case
|
|
289
|
+
folding is a locale decision this package cannot make for a host, so a table that wants it supplies
|
|
290
|
+
a `CellMatcher` for that column. The same is true of collation: `text` compares with the language's
|
|
291
|
+
own string order, not a locale-aware collator.
|
|
292
|
+
|
|
293
|
+
### number
|
|
294
|
+
|
|
295
|
+
This fence declares a `number` column.
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
import type { NumberColumn } from '@orkestrel/table'
|
|
299
|
+
|
|
300
|
+
const age: NumberColumn = { cell: 'number', key: 'age', label: 'Age' }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
A cell must be a finite number. `NaN` and either infinity are values a column cannot hold, so a
|
|
304
|
+
write carrying one raises `TableError` coded `CELL`.
|
|
305
|
+
|
|
306
|
+
### flag
|
|
307
|
+
|
|
308
|
+
This fence declares a `flag` column.
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
import type { FlagColumn } from '@orkestrel/table'
|
|
312
|
+
|
|
313
|
+
const active: FlagColumn = { cell: 'flag', key: 'active', label: 'Active' }
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`flag` takes `equals` and nothing else. `contains` has no meaning against a boolean and `between`
|
|
317
|
+
carries bounds it could not order usefully, so a filter carrying either against a `flag` column
|
|
318
|
+
raises `TableError` coded `CELL`.
|
|
319
|
+
|
|
320
|
+
### choice
|
|
321
|
+
|
|
322
|
+
This fence declares a `choice` column with its ordered choices.
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
import type { ChoiceColumn } from '@orkestrel/table'
|
|
326
|
+
|
|
327
|
+
const status: ChoiceColumn = {
|
|
328
|
+
cell: 'choice',
|
|
329
|
+
key: 'status',
|
|
330
|
+
label: 'Status',
|
|
331
|
+
choices: [
|
|
332
|
+
{ value: 'draft', label: 'Draft' },
|
|
333
|
+
{ value: 'live', label: 'Live' },
|
|
334
|
+
{ value: 'archived', label: 'Archived', help: 'Kept, not shown' },
|
|
335
|
+
],
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
A `choice` cell holds one of the declared values, and a cell holding a value the list does not offer
|
|
340
|
+
is refused at admission. The order the list declares is the order the column sorts by, which is what
|
|
341
|
+
lets a status column sort draft before live before archived rather than alphabetically — the one
|
|
342
|
+
thing a plain `text` column could not do without a comparator per consumer.
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
import { compareCells } from '@orkestrel/table'
|
|
346
|
+
import type { ChoiceColumn } from '@orkestrel/table'
|
|
347
|
+
|
|
348
|
+
const status: ChoiceColumn = {
|
|
349
|
+
cell: 'choice',
|
|
350
|
+
key: 'status',
|
|
351
|
+
choices: [
|
|
352
|
+
{ value: 'draft', label: 'Draft' },
|
|
353
|
+
{ value: 'live', label: 'Live' },
|
|
354
|
+
],
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
compareCells(status, 'draft', 'live') < 0 // true — declared order, not alphabetical
|
|
358
|
+
compareCells({ cell: 'text', key: 'name' }, 'draft', 'live') < 0 // true — lexical, and here they agree
|
|
359
|
+
compareCells({ cell: 'flag', key: 'ok' }, false, true) < 0 // true — false before true
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Temporal data is text
|
|
363
|
+
|
|
364
|
+
There is no temporal cell. A date, a time, and a timestamp are `text` columns holding ISO strings,
|
|
365
|
+
because **lexical order is chronological order for one canonical ISO representation** — one offset,
|
|
366
|
+
one precision, and one normalized spelling for each instant across the whole column. That is the
|
|
367
|
+
whole reason the format exists, and it is what lets a `between` filter over such a column be a date
|
|
368
|
+
range with no new operator, no new cell, and no calendar inside this package.
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
import { matchesFilter } from '@orkestrel/table'
|
|
372
|
+
import type { BetweenFilter, TextColumn } from '@orkestrel/table'
|
|
373
|
+
|
|
374
|
+
const when: TextColumn = { cell: 'text', key: 'when', label: 'Signed up' }
|
|
375
|
+
const range: BetweenFilter = {
|
|
376
|
+
column: 'when',
|
|
377
|
+
operator: 'between',
|
|
378
|
+
minimum: '2026-01-01',
|
|
379
|
+
maximum: '2026-06-30',
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
matchesFilter(when, '2026-03-14', range) // true
|
|
383
|
+
matchesFilter(when, '2025-12-31', range) // false
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
That is a boundary drawn on purpose, and it asks these things of the values a column holds. Every
|
|
387
|
+
one of them is a comparison on the spelling, because that is the only comparison there is here.
|
|
388
|
+
|
|
389
|
+
**One offset.** A column mixing offsets is not in chronological order:
|
|
390
|
+
`'2026-01-01T00:00:00+01:00'` sorts after `'2025-12-31T23:30:00Z'` and names an instant half an hour
|
|
391
|
+
earlier than it. Normalize to UTC `Z` before a value is stored — the usual answer — or supply a
|
|
392
|
+
`CellComparator` for that column that reads the offset before it compares.
|
|
393
|
+
|
|
394
|
+
**One precision.** `'09:00'` sorts before `'09:00:00'`, so a column mixing the two orders them by
|
|
395
|
+
spelling rather than by clock. A filter's operands must match their cells the same way.
|
|
396
|
+
|
|
397
|
+
**Normalized midnight.** ISO permits `24:00:00Z`, but `2026-01-01T24:00:00Z` names the same instant
|
|
398
|
+
as `2026-01-02T00:00:00Z` while sorting before it. Normalize midnight to the next day's
|
|
399
|
+
`00:00:00Z`, and use that spelling in filter operands too.
|
|
400
|
+
|
|
401
|
+
**No calendar.** No value is checked against a real date, so `'2026-02-31'` is an ordinary `text`
|
|
402
|
+
cell here. A host that renders a date control already refuses an impossible day, and a domain that
|
|
403
|
+
needs the check adds it at its own door.
|
|
404
|
+
|
|
405
|
+
### meta
|
|
406
|
+
|
|
407
|
+
`ColumnBase.meta` is a bounded JSON carrier for whatever the schema declines to model. The table
|
|
408
|
+
never reads it, no comparison sees it, no filter tests it, and it round-trips verbatim key for key.
|
|
409
|
+
This package defines no key in it, so every key belongs to the host: an alignment, a number format,
|
|
410
|
+
a pixel width, an icon name.
|
|
411
|
+
|
|
412
|
+
`hidden` sits beside it and is the one presentation fact the schema does carry, because hiding a
|
|
413
|
+
column changes nothing about the data. A hidden column is still sorted, still filtered, and still
|
|
414
|
+
serialized; whether it is drawn is the host's read of that flag.
|
|
415
|
+
|
|
416
|
+
### Reading a column
|
|
417
|
+
|
|
418
|
+
These reads turn a column key into a decision, and each is exported because a host asking the same
|
|
419
|
+
question before it writes needs the same answer. `extractColumn` finds the column a key names, and
|
|
420
|
+
`matchesCell` is the gate every write, every seed, and every filter operand passes through.
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
import { extractColumn, matchesCell } from '@orkestrel/table'
|
|
424
|
+
import type { TableSchema } from '@orkestrel/table'
|
|
425
|
+
|
|
426
|
+
const schema: TableSchema = {
|
|
427
|
+
key: 'id',
|
|
428
|
+
columns: [
|
|
429
|
+
{ cell: 'text', key: 'id' },
|
|
430
|
+
{ cell: 'number', key: 'age' },
|
|
431
|
+
],
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
extractColumn(schema, 'age')?.cell // 'number'
|
|
435
|
+
extractColumn(schema, 'colour') // undefined — the schema declares no such column
|
|
436
|
+
|
|
437
|
+
matchesCell({ cell: 'number', key: 'age' }, 36) // true
|
|
438
|
+
matchesCell({ cell: 'number', key: 'age' }, '36') // false — a numeric string is not a number
|
|
439
|
+
matchesCell({ cell: 'number', key: 'age' }, Number.NaN) // false — a cell must be finite
|
|
440
|
+
matchesCell({ cell: 'choice', key: 'status', choices: [{ value: 'live', label: 'Live' }] }, 'draft')
|
|
441
|
+
// false — the list does not offer it
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
`extractColumn` returning `undefined` is how every `COLUMN` refusal starts: a term or a filter
|
|
445
|
+
naming a column the schema does not declare has nothing to be measured against.
|
|
446
|
+
|
|
447
|
+
### Overriding a column
|
|
448
|
+
|
|
449
|
+
`TableOptions.comparators` and `TableOptions.matchers` are keyed by column key, and each entry
|
|
450
|
+
replaces — for that column alone — the comparison or the test its `cell` fixes. They are the reason
|
|
451
|
+
no function belongs in the schema: behavior is supplied where the table is opened, and the schema
|
|
452
|
+
stays data that crosses a wire whole.
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
import { createTable } from '@orkestrel/table'
|
|
456
|
+
import type { CellComparator, CellMatcher } from '@orkestrel/table'
|
|
457
|
+
|
|
458
|
+
const natural: CellComparator = (left, right) =>
|
|
459
|
+
String(left ?? '').localeCompare(String(right ?? ''), undefined, { numeric: true })
|
|
460
|
+
|
|
461
|
+
const loose: CellMatcher = (cell, filter) =>
|
|
462
|
+
filter.operator === 'contains' &&
|
|
463
|
+
String(cell ?? '')
|
|
464
|
+
.toLowerCase()
|
|
465
|
+
.includes(filter.text.toLowerCase())
|
|
466
|
+
|
|
467
|
+
const table = createTable(
|
|
468
|
+
{
|
|
469
|
+
key: 'id',
|
|
470
|
+
columns: [
|
|
471
|
+
{ cell: 'text', key: 'id' },
|
|
472
|
+
{ cell: 'text', key: 'name' },
|
|
473
|
+
],
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
rows: [{ id: '1', name: 'ada' }],
|
|
477
|
+
comparators: { name: natural },
|
|
478
|
+
matchers: { name: loose },
|
|
479
|
+
},
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
table.filter.set({ column: 'name', operator: 'contains', text: 'ADA' })
|
|
483
|
+
table.count // 1 — the matcher folded the case; the default would not have
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
An override receives `undefined` for a row carrying no cell there, so it decides absence for itself.
|
|
487
|
+
A comparator always describes ascending order and `TableDirection` is applied afterwards, so one
|
|
488
|
+
comparator serves either direction. An override for a column the schema does not declare is never
|
|
489
|
+
consulted and is not an error: the schema decides which columns exist, and the option only says how
|
|
490
|
+
one of them behaves.
|
|
491
|
+
|
|
492
|
+
## Identity
|
|
493
|
+
|
|
494
|
+
Every row carries its own identity, in the cell named by `TableSchema.key`, as a non-empty string.
|
|
495
|
+
|
|
496
|
+
`key` is **required**. It must name a declared column, and the table refuses a row whose cell there
|
|
497
|
+
is missing, empty, or not a string. There is no default column, no positional fallback, and no key
|
|
498
|
+
generation: an index is a position, and a position is not an identity — the moment a sort moves a
|
|
499
|
+
row, a position that named it names somebody else.
|
|
500
|
+
|
|
501
|
+
That is a deliberate divergence from `@orkestrel/database`, whose `DEFAULT_PRIMARY` assumes `id`
|
|
502
|
+
when a table declares no primary column. A database row arrives from a store that already gave it a
|
|
503
|
+
key, so assuming the usual name saves a declaration and costs nothing. A table's rows arrive from a
|
|
504
|
+
caller who may have joined, projected, or invented them in the browser, so the same assumption picks
|
|
505
|
+
a column that may not exist, or picks one that exists and is not unique. Declaring the key is one
|
|
506
|
+
line, and it is the line every refusal described later is measured against.
|
|
507
|
+
|
|
508
|
+
```ts
|
|
509
|
+
import { createTable, isTableError } from '@orkestrel/table'
|
|
510
|
+
|
|
511
|
+
const table = createTable({
|
|
512
|
+
key: 'id',
|
|
513
|
+
columns: [
|
|
514
|
+
{ cell: 'text', key: 'id' },
|
|
515
|
+
{ cell: 'text', key: 'name' },
|
|
516
|
+
],
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
table.rows.add({ id: '7', name: 'Ada' })
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
table.rows.add({ id: '7', name: 'Grace' })
|
|
523
|
+
} catch (error) {
|
|
524
|
+
if (isTableError(error)) error.code // 'KEY' — already taken
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
table.rows.add({ name: 'Alan' })
|
|
529
|
+
} catch (error) {
|
|
530
|
+
if (isTableError(error)) error.code // 'KEY' — no identity at all
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
table.rows.rows().length // 1 — a refused write changed nothing
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
These rules follow from it, and each one is a refusal rather than a repair:
|
|
537
|
+
|
|
538
|
+
- **A key is unique.** `add` raises `KEY` for a key the table already holds, and for a key repeated
|
|
539
|
+
inside one batch. Every row in the batch is checked before any is admitted, so one duplicate
|
|
540
|
+
admits none of them.
|
|
541
|
+
- **A key cannot move.** `update` merges: the cells given replace the cells held and the cells left
|
|
542
|
+
out stay as they are. A row is found by the key it carries, so writing a different key writes a
|
|
543
|
+
different row — or writes nothing, if that row does not exist.
|
|
544
|
+
- **A row is owned.** The table clones and freezes a row at admission, so the object a caller keeps
|
|
545
|
+
is no longer the row the table holds, and `row()` and `rows()` hand back frozen copies rather than
|
|
546
|
+
internal references.
|
|
547
|
+
- **Selection and expansion hold keys.** Never rows, never positions. A pick survives a sort, a
|
|
548
|
+
filter, and a page turn, and a row that leaves the table takes its key out of both sets with it.
|
|
549
|
+
|
|
550
|
+
`update` is where that second rule is felt. It merges, so a call carries only the cells it means to
|
|
551
|
+
change, and the row it writes is the one carrying the key it names — never a rename.
|
|
552
|
+
|
|
553
|
+
```ts
|
|
554
|
+
import { createTable } from '@orkestrel/table'
|
|
555
|
+
|
|
556
|
+
const table = createTable(
|
|
557
|
+
{
|
|
558
|
+
key: 'id',
|
|
559
|
+
columns: [
|
|
560
|
+
{ cell: 'text', key: 'id' },
|
|
561
|
+
{ cell: 'text', key: 'name' },
|
|
562
|
+
{ cell: 'number', key: 'age' },
|
|
563
|
+
],
|
|
564
|
+
},
|
|
565
|
+
{ rows: [{ id: '1', name: 'Ada', age: 36 }] },
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
table.rows.update({ id: '1', age: 37 }) // true
|
|
569
|
+
table.rows.row('1') // { id: '1', name: 'Ada', age: 37 } — `name` was left out and stayed
|
|
570
|
+
table.rows.update({ id: '9', name: 'Nobody' }) // false — no row carries that key
|
|
571
|
+
table.rows.rows().length // 1 — a different key is a different row, so nothing was added
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
`extractKey` is the read those rules are built on, and it is exported because a host doing the same
|
|
575
|
+
check before it calls needs the same answer.
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
import { extractKey } from '@orkestrel/table'
|
|
579
|
+
import type { TableSchema } from '@orkestrel/table'
|
|
580
|
+
|
|
581
|
+
const schema: TableSchema = { key: 'id', columns: [{ cell: 'text', key: 'id' }] }
|
|
582
|
+
|
|
583
|
+
extractKey(schema, { id: '7' }) // '7'
|
|
584
|
+
extractKey(schema, { id: '' }) // undefined — empty is not an identity
|
|
585
|
+
extractKey(schema, { name: 'Ada' }) // undefined — no key cell at all
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
## The lens
|
|
589
|
+
|
|
590
|
+
Sort, filter, and page are one lens over one row store. Reading `view` applies them in a fixed
|
|
591
|
+
order — **filter, then sort, then page** — and that order is not configurable, because the other
|
|
592
|
+
orders answer a different question: sorting before filtering sorts rows nobody will see, and paging
|
|
593
|
+
before sorting pages the wrong rows onto every page.
|
|
594
|
+
|
|
595
|
+
### Sorting
|
|
596
|
+
|
|
597
|
+
The table holds at most one term per column and applies them in the order they were set. The first
|
|
598
|
+
term decides, and each later term breaks the tie the terms before it left. Rows no term separates
|
|
599
|
+
keep the order the table holds them in, so the sort is stable and the row store's own order is the
|
|
600
|
+
final tiebreak.
|
|
601
|
+
|
|
602
|
+
```ts
|
|
603
|
+
import { createTable } from '@orkestrel/table'
|
|
604
|
+
|
|
605
|
+
const table = createTable(
|
|
606
|
+
{
|
|
607
|
+
key: 'id',
|
|
608
|
+
columns: [
|
|
609
|
+
{ cell: 'text', key: 'id' },
|
|
610
|
+
{ cell: 'text', key: 'team' },
|
|
611
|
+
{ cell: 'number', key: 'age' },
|
|
612
|
+
],
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
rows: [
|
|
616
|
+
{ id: '1', team: 'blue', age: 40 },
|
|
617
|
+
{ id: '2', team: 'red', age: 30 },
|
|
618
|
+
{ id: '3', team: 'blue', age: 30 },
|
|
619
|
+
],
|
|
620
|
+
},
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
table.sort.set([
|
|
624
|
+
{ column: 'team', direction: 'ascending' },
|
|
625
|
+
{ column: 'age', direction: 'descending' },
|
|
626
|
+
])
|
|
627
|
+
|
|
628
|
+
table.view.map((row) => row.id) // ['1', '3', '2'] — team first, age breaking the tie
|
|
629
|
+
table.sort.order('age') // { column: 'age', direction: 'descending' }
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
There is no cycling verb. Which direction a heading offers next is the host's decision, so a host
|
|
633
|
+
reads the column's term, decides, and then calls `set` or `remove`. Setting a term for a column
|
|
634
|
+
already sorted replaces that column's direction **in place**, keeping its position in the list; every
|
|
635
|
+
other term joins the end.
|
|
636
|
+
|
|
637
|
+
Sorting and filtering keep the same list: at most one term per column, replaced in place, and
|
|
638
|
+
compared as a whole before anything is announced. `mergeTerms` performs that write, `removeTerms`
|
|
639
|
+
the drop, and `matchesTerms` the comparison, which takes the operand test from its caller because a
|
|
640
|
+
direction and a filter's operands are not compared the same way. They are exported so that a host
|
|
641
|
+
holding a lens of its own gets the same list arithmetic without writing it again.
|
|
642
|
+
|
|
643
|
+
```ts
|
|
644
|
+
import { matchesTerms, mergeTerms, removeTerms } from '@orkestrel/table'
|
|
645
|
+
import type { TableOrder } from '@orkestrel/table'
|
|
646
|
+
|
|
647
|
+
const current: readonly TableOrder[] = [
|
|
648
|
+
{ column: 'team', direction: 'ascending' },
|
|
649
|
+
{ column: 'age', direction: 'descending' },
|
|
650
|
+
]
|
|
651
|
+
const next = mergeTerms(current, [{ column: 'age', direction: 'ascending' }])
|
|
652
|
+
|
|
653
|
+
next.map((order) => order.column) // ['team', 'age'] — the replaced term keeps its place
|
|
654
|
+
matchesTerms(next, current, (order, other) => order.direction === other.direction) // false
|
|
655
|
+
removeTerms(next, ['team']).map((order) => order.column) // ['age']
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
`sortRows` is the same pass without a table, for a caller that has rows and terms and no entity. It
|
|
659
|
+
sorts a copy, so the list handed to it never moves.
|
|
660
|
+
|
|
661
|
+
```ts
|
|
662
|
+
import { sortRows } from '@orkestrel/table'
|
|
663
|
+
import type { TableSchema } from '@orkestrel/table'
|
|
664
|
+
|
|
665
|
+
const schema: TableSchema = {
|
|
666
|
+
key: 'id',
|
|
667
|
+
columns: [
|
|
668
|
+
{ cell: 'text', key: 'id' },
|
|
669
|
+
{ cell: 'number', key: 'age' },
|
|
670
|
+
],
|
|
671
|
+
}
|
|
672
|
+
const rows = [{ id: '1', age: 40 }, { id: '2' }, { id: '3', age: 30 }]
|
|
673
|
+
|
|
674
|
+
sortRows(schema, rows, [{ column: 'age', direction: 'ascending' }]).map((row) => row.id)
|
|
675
|
+
// ['2', '3', '1'] — an absent cell sorts before every present one
|
|
676
|
+
sortRows(schema, rows, []).map((row) => row.id) // ['1', '2', '3'] — no term, no movement
|
|
677
|
+
rows.map((row) => row.id) // ['1', '2', '3'] — the input is untouched
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
### Filtering
|
|
681
|
+
|
|
682
|
+
The table holds at most one filter per column and keeps every row all of them accept. Composition is
|
|
683
|
+
**and** in this version: there is no either-or, and no nesting.
|
|
684
|
+
|
|
685
|
+
A filter names a column and carries only the operands its operator uses, so a `between` missing a
|
|
686
|
+
bound cannot be written down. An operator the column's cell does not admit raises `TableError` coded
|
|
687
|
+
`CELL`, and an operand the column cannot hold raises the same — a `contains` against a `number`
|
|
688
|
+
column and a `between` whose bounds are the wrong shape are both refusals, not empty results.
|
|
689
|
+
|
|
690
|
+
```ts
|
|
691
|
+
import { createTable, isTableError } from '@orkestrel/table'
|
|
692
|
+
|
|
693
|
+
const table = createTable(
|
|
694
|
+
{
|
|
695
|
+
key: 'id',
|
|
696
|
+
columns: [
|
|
697
|
+
{ cell: 'text', key: 'id' },
|
|
698
|
+
{ cell: 'text', key: 'name' },
|
|
699
|
+
{ cell: 'number', key: 'age' },
|
|
700
|
+
],
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
rows: [
|
|
704
|
+
{ id: '1', name: 'Ada', age: 36 },
|
|
705
|
+
{ id: '2', name: 'Grace', age: 45 },
|
|
706
|
+
],
|
|
707
|
+
},
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
table.filter.set([
|
|
711
|
+
{ column: 'name', operator: 'contains', text: 'a' },
|
|
712
|
+
{ column: 'age', operator: 'between', minimum: 40, maximum: 50 },
|
|
713
|
+
])
|
|
714
|
+
|
|
715
|
+
table.count // 1 — both filters, and only Grace satisfies both
|
|
716
|
+
table.view.map((row) => row.name) // ['Grace']
|
|
717
|
+
|
|
718
|
+
try {
|
|
719
|
+
table.filter.set({ column: 'age', operator: 'contains', text: '4' })
|
|
720
|
+
} catch (error) {
|
|
721
|
+
if (isTableError(error)) error.code // 'CELL' — `contains` has no meaning against a number
|
|
722
|
+
}
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
That admissibility rule has one home. `admitsFilter` answers whether a column takes a filter and
|
|
726
|
+
every operand it carries, and both doors read it: `filter.set` raises `CELL` when it says no, and
|
|
727
|
+
`matchesFilter` returns `false` for the same filter rather than testing it. So a host can ask the
|
|
728
|
+
question before it offers the control — which operators to put in a column's menu, and whether the
|
|
729
|
+
value somebody typed is one that column can take.
|
|
730
|
+
|
|
731
|
+
```ts
|
|
732
|
+
import { admitsFilter } from '@orkestrel/table'
|
|
733
|
+
import type { NumberColumn } from '@orkestrel/table'
|
|
734
|
+
|
|
735
|
+
const age: NumberColumn = { cell: 'number', key: 'age', label: 'Age' }
|
|
736
|
+
|
|
737
|
+
admitsFilter(age, { column: 'age', operator: 'between', minimum: 30, maximum: 40 }) // true
|
|
738
|
+
admitsFilter(age, { column: 'age', operator: 'contains', text: '3' }) // false — not an operator a number takes
|
|
739
|
+
admitsFilter(age, { column: 'age', operator: 'equals', value: '36' }) // false — an operand the column cannot hold
|
|
740
|
+
admitsFilter(age, { column: 'name', operator: 'equals', value: 36 }) // false — the filter names another column
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
`filterRows` is the same pass without a table, and it keeps the order it was given.
|
|
744
|
+
|
|
745
|
+
```ts
|
|
746
|
+
import { filterRows } from '@orkestrel/table'
|
|
747
|
+
import type { TableSchema } from '@orkestrel/table'
|
|
748
|
+
|
|
749
|
+
const schema: TableSchema = {
|
|
750
|
+
key: 'id',
|
|
751
|
+
columns: [
|
|
752
|
+
{ cell: 'text', key: 'id' },
|
|
753
|
+
{ cell: 'text', key: 'name' },
|
|
754
|
+
],
|
|
755
|
+
}
|
|
756
|
+
const rows = [
|
|
757
|
+
{ id: '1', name: 'Ada' },
|
|
758
|
+
{ id: '2', name: 'Grace' },
|
|
759
|
+
{ id: '3', name: 'Bob' },
|
|
760
|
+
]
|
|
761
|
+
|
|
762
|
+
const lower = filterRows(schema, rows, [{ column: 'name', operator: 'contains', text: 'a' }])
|
|
763
|
+
const upper = filterRows(schema, rows, [{ column: 'name', operator: 'contains', text: 'A' }])
|
|
764
|
+
|
|
765
|
+
lower.map((row) => row.name) // ['Ada', 'Grace']
|
|
766
|
+
upper.map((row) => row.name) // ['Ada'] — `contains` is case-sensitive
|
|
767
|
+
filterRows(schema, rows, []).length // 3 — no filter refuses nothing
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
### Pagination
|
|
771
|
+
|
|
772
|
+
`page` is the state, counted from one. `offset` and `count` are worked out from it and from the rows
|
|
773
|
+
the filter admits, so nothing here can drift out of step with what the table holds.
|
|
774
|
+
|
|
775
|
+
- `limit` is how many rows a page holds, or `undefined` when the table is not paged. An unpaged table
|
|
776
|
+
reports `page` 1, `offset` 0, and `count` 1, and its `view` is every row the filter admits.
|
|
777
|
+
- `offset` counts the rows skipped, from zero, which is exactly what a database query asks for.
|
|
778
|
+
- `count` is `max(1, ceil(rows / limit))`, so a paged table whose filter admits no rows still
|
|
779
|
+
reports one page, and that page's `view` is empty. There is no page zero, and a host drawing
|
|
780
|
+
"page 1 of 1" over an empty list needs no separate case for it.
|
|
781
|
+
- A page beyond the last one clamps to the last one. Narrowing the filter therefore moves the page
|
|
782
|
+
on its own, and moving to page 9 of 3 shows page 3 rather than nothing.
|
|
783
|
+
- `resize` keeps the first row the view was showing, so the page moves to wherever that row now
|
|
784
|
+
falls. Resizing is a change of magnification, not a jump to the top.
|
|
785
|
+
|
|
786
|
+
```ts
|
|
787
|
+
import { createTable } from '@orkestrel/table'
|
|
788
|
+
|
|
789
|
+
const table = createTable(
|
|
790
|
+
{ key: 'id', columns: [{ cell: 'text', key: 'id' }] },
|
|
791
|
+
{ rows: [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }], limit: 2 },
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
table.pagination.count // 3 — five rows, two to a page
|
|
795
|
+
table.pagination.move(9)
|
|
796
|
+
table.pagination.page // 3 — clamped to the last page
|
|
797
|
+
table.pagination.offset // 4
|
|
798
|
+
table.view.map((row) => row.id) // ['5']
|
|
799
|
+
|
|
800
|
+
table.pagination.resize()
|
|
801
|
+
table.pagination.limit // undefined — not paged
|
|
802
|
+
table.view.length // 5
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
This vocabulary is deliberately the one `@orkestrel/database` and `@orkestrel/relation` already use:
|
|
806
|
+
`ascending` and `descending` for direction, a zero-based `offset` that means rows skipped, and
|
|
807
|
+
`limit` for the page size. A host doing server-side paging reads `sort.orders()`,
|
|
808
|
+
`filter.filters()`, and `pagination.offset` and `pagination.limit`, and hands them to a query
|
|
809
|
+
untranslated. Nothing is imported from either package and nothing is re-exported: the compatibility
|
|
810
|
+
is in the words, so each side agrees without depending on the other.
|
|
811
|
+
|
|
812
|
+
### Selection and expansion
|
|
813
|
+
|
|
814
|
+
Selection and expansion hold `TableKey` sets and nothing else, and each offers the same verbs:
|
|
815
|
+
`select` and `expand` add, `clear` removes, and `toggle` turns one row around. Each takes no argument to mean
|
|
816
|
+
every row, one key to mean one row, and a key list to mean those rows, and a list is checked in full
|
|
817
|
+
before any of it moves.
|
|
818
|
+
|
|
819
|
+
Selecting with no argument picks **every row the table holds** — not every visible one. A host
|
|
820
|
+
offering a header checkbox that picks the page hands that page's keys over instead, which is one
|
|
821
|
+
line over `view` and keeps the ambiguity where the host can see it.
|
|
822
|
+
|
|
823
|
+
```ts
|
|
824
|
+
import { createTable } from '@orkestrel/table'
|
|
825
|
+
|
|
826
|
+
const table = createTable(
|
|
827
|
+
{ key: 'id', columns: [{ cell: 'text', key: 'id' }] },
|
|
828
|
+
{ rows: [{ id: '1' }, { id: '2' }, { id: '3' }] },
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
table.selection.select(table.view.map((row) => String(row.id))) // the page, not the table
|
|
832
|
+
table.selection.keys.size // 3
|
|
833
|
+
|
|
834
|
+
table.selection.toggle('2')
|
|
835
|
+
table.selection.keys.has('2') // false
|
|
836
|
+
|
|
837
|
+
table.rows.remove('1')
|
|
838
|
+
table.selection.keys.has('1') // false — a row that leaves takes its key with it
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
What an opened row shows beside it is the host's to draw. Expansion says which rows are open and
|
|
842
|
+
stops there.
|
|
843
|
+
|
|
844
|
+
```ts
|
|
845
|
+
import { createTable } from '@orkestrel/table'
|
|
846
|
+
|
|
847
|
+
const table = createTable(
|
|
848
|
+
{ key: 'id', columns: [{ cell: 'text', key: 'id' }] },
|
|
849
|
+
{ rows: [{ id: '1' }, { id: '2' }, { id: '3' }] },
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
table.expansion.expand() // no argument — every row the table holds
|
|
853
|
+
table.expansion.keys.size // 3
|
|
854
|
+
|
|
855
|
+
table.expansion.clear('2')
|
|
856
|
+
table.expansion.expand(['2', '9']) // false — '9' names no row, so neither one opened
|
|
857
|
+
table.expansion.keys.has('2') // false
|
|
858
|
+
|
|
859
|
+
table.expansion.toggle('2')
|
|
860
|
+
table.expansion.keys.size // 3
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
The selection and the expansion manager are the same algorithm over their own key set, so it is
|
|
864
|
+
written once, and so is the shell around it. `KeyManager`, the shared key-set shell, holds the store reads, the lifecycle gate, and the
|
|
865
|
+
announcement, and each manager supplies only its own verbs and its own event. The shell is internal,
|
|
866
|
+
like the managers that compose it.
|
|
867
|
+
|
|
868
|
+
`computeKeys` is the key-set engine underneath the shell, and it is published. It takes the keys a
|
|
869
|
+
caller may address, the set as it stands, the 0/1/N argument, and a decision made per key from that
|
|
870
|
+
key's own membership. It returns `undefined` when any requested key is unknown, which is the `false`
|
|
871
|
+
those verbs report. It returns the set it was handed when nothing moved, which is how a no-op stays
|
|
872
|
+
silent. Otherwise it returns the next set. A host keeping a third key set of its own gets the same
|
|
873
|
+
atomicity without writing it again.
|
|
874
|
+
|
|
875
|
+
```ts
|
|
876
|
+
import { computeKeys } from '@orkestrel/table'
|
|
877
|
+
import type { TableKey } from '@orkestrel/table'
|
|
878
|
+
|
|
879
|
+
const known: readonly TableKey[] = ['1', '2', '3']
|
|
880
|
+
const picked: ReadonlySet<TableKey> = new Set(['1'])
|
|
881
|
+
|
|
882
|
+
computeKeys(known, picked, '2', () => true)?.size // 2 — '2' joins '1'
|
|
883
|
+
computeKeys(known, picked, '9', () => true) // undefined — '9' is not a key the caller may address
|
|
884
|
+
computeKeys(known, picked, '1', () => true) === picked // true — nothing moved, so nothing is announced
|
|
885
|
+
computeKeys(known, picked, undefined, (included) => !included)?.size // 2 — every key turned around
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
## Lifecycle and state
|
|
889
|
+
|
|
890
|
+
A table is a live projection, not a document that settles. It has no terminal success state, no
|
|
891
|
+
submit, and no result: rows arrive and leave for as long as the host holds it, and every read
|
|
892
|
+
answers from what it holds at that moment.
|
|
893
|
+
|
|
894
|
+
The verbs that end things end different things.
|
|
895
|
+
|
|
896
|
+
- **`clear` resets, and the table stays open.** Every row goes, and sort, filter, selection,
|
|
897
|
+
expansion, and the page all reset to how the table opened. The schema and the options do not move.
|
|
898
|
+
A cleared table takes rows again immediately.
|
|
899
|
+
- **`destroy` tears down, and the table stays readable.** Calling it twice does what calling it once
|
|
900
|
+
did. Afterwards every write raises `TableError` coded `DESTROYED`, while every getter still answers
|
|
901
|
+
what the table last held — so a host can read its way out of teardown without catching anything.
|
|
902
|
+
|
|
903
|
+
`destroyed` is the readable fact, and it exists so a host handed a `TableInterface` it did not
|
|
904
|
+
construct does not have to use an exception as control flow.
|
|
905
|
+
|
|
906
|
+
```ts
|
|
907
|
+
import { createTable, isTableError } from '@orkestrel/table'
|
|
908
|
+
|
|
909
|
+
const table = createTable(
|
|
910
|
+
{ key: 'id', columns: [{ cell: 'text', key: 'id' }] },
|
|
911
|
+
{ rows: [{ id: '1' }, { id: '2' }] },
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
table.clear()
|
|
915
|
+
table.count // 0
|
|
916
|
+
table.rows.add({ id: '3' }) // a cleared table is still open
|
|
917
|
+
|
|
918
|
+
table.destroy()
|
|
919
|
+
table.destroy() // idempotent
|
|
920
|
+
table.destroyed // true
|
|
921
|
+
table.count // 1 — every getter still answers
|
|
922
|
+
|
|
923
|
+
try {
|
|
924
|
+
table.rows.add({ id: '4' })
|
|
925
|
+
} catch (error) {
|
|
926
|
+
if (isTableError(error)) error.code // 'DESTROYED'
|
|
927
|
+
}
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
A write is every call that could change what the table holds: `rows.add`, `rows.update`,
|
|
931
|
+
`rows.move`, `rows.remove`, `sort.set`, `sort.remove`, `filter.set`, `filter.remove`,
|
|
932
|
+
`selection.select`, `selection.clear`, `selection.toggle`, `expansion.expand`, `expansion.clear`,
|
|
933
|
+
`expansion.toggle`, `pagination.move`, `pagination.resize`, and the table's own `clear`. Every one of
|
|
934
|
+
them raises `DESTROYED` after teardown. `destroy` itself does not.
|
|
935
|
+
|
|
936
|
+
## Events
|
|
937
|
+
|
|
938
|
+
Each event carries the fact that moved. No listener sees a state the table has not finished writing.
|
|
939
|
+
An event fires only when something actually moved: a write that changes nothing announces nothing.
|
|
940
|
+
|
|
941
|
+
| Event | Payload | Fires |
|
|
942
|
+
| ---------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
943
|
+
| `write` | the row's `key` | Once per row admitted, written over, or moved to a different place, in the order the table wrote them. A listener that wants the row reads it back. |
|
|
944
|
+
| `remove` | the row's `key` | Once per row taken out, in the order the table removed them. A `clear` is the exception; see its row. |
|
|
945
|
+
| `sort` | every current `TableOrder` | Whenever the term list's content changes, carrying the whole list as it now stands. |
|
|
946
|
+
| `filter` | every current `TableFilter` | Whenever the filter list's content changes, carrying the whole list as it now stands. |
|
|
947
|
+
| `select` | the picked `TableKey` set | Whenever the picked set changes, carrying the whole set — including when removing rows pruned it. |
|
|
948
|
+
| `expand` | the opened `TableKey` set | Whenever the opened set changes, carrying the whole set — including when removing rows pruned it. |
|
|
949
|
+
| `paginate` | the `page` now shown | Whenever the paged window moves: a `move`, a `resize`, and a clamp caused by a narrower filter or by rows leaving. |
|
|
950
|
+
| `clear` | nothing | On a completed `clear`. It is the whole announcement of that reset: no `remove`, no `sort`, no `filter`, no `select`, no `expand`, and no `paginate` is emitted beside it. |
|
|
951
|
+
|
|
952
|
+
A payload is owned by the listener. `sort` and `filter` carry the whole axis because a single term is
|
|
953
|
+
unreadable without the rest, and `select` and `expand` carry the same `ReadonlySet` shape the
|
|
954
|
+
managers' `keys` getters publish — one concept, one shape, emitted as a copy so nothing a listener
|
|
955
|
+
holds moves underneath it.
|
|
956
|
+
|
|
957
|
+
One call can announce more than one thing, and the order is fixed: **the rows first, then the axes
|
|
958
|
+
they disturbed, in the order `select`, `expand`, `paginate`.** Removing two picked rows from a paged
|
|
959
|
+
table therefore emits `remove`, `remove`, `select`, and then `paginate` if the page had to clamp.
|
|
960
|
+
|
|
961
|
+
`TableOptions.rows` seeds quietly. Seeding announces nothing at all, so a table opens silent and
|
|
962
|
+
every later write is heard.
|
|
963
|
+
|
|
964
|
+
Wire listeners at construction through `TableOptions.on`, or afterwards through the `emitter`. Each
|
|
965
|
+
reaches the same typed emitter, and a listener that throws is isolated and reported to
|
|
966
|
+
`TableOptions.error` rather than breaking its siblings or the table.
|
|
967
|
+
|
|
968
|
+
```ts
|
|
969
|
+
import { createTable } from '@orkestrel/table'
|
|
970
|
+
|
|
971
|
+
const seen: string[] = []
|
|
972
|
+
|
|
973
|
+
const table = createTable(
|
|
974
|
+
{
|
|
975
|
+
key: 'id',
|
|
976
|
+
columns: [
|
|
977
|
+
{ cell: 'text', key: 'id' },
|
|
978
|
+
{ cell: 'number', key: 'age' },
|
|
979
|
+
],
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
rows: [{ id: '1', age: 36 }],
|
|
983
|
+
on: {
|
|
984
|
+
write: (key) => seen.push(`write ${key}`),
|
|
985
|
+
sort: (orders) => seen.push(`sort ${orders.length}`),
|
|
986
|
+
},
|
|
987
|
+
error: (error) => console.error(error),
|
|
988
|
+
},
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
table.emitter.on('clear', () => seen.push('clear'))
|
|
992
|
+
|
|
993
|
+
table.rows.add({ id: '2', age: 45 })
|
|
994
|
+
table.sort.set({ column: 'age', direction: 'ascending' })
|
|
995
|
+
table.sort.set({ column: 'age', direction: 'ascending' }) // the same term twice
|
|
996
|
+
table.clear()
|
|
997
|
+
|
|
998
|
+
seen // ['write 2', 'sort 1', 'clear'] — the repeated term moved nothing and said nothing
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
## Wire safety
|
|
1002
|
+
|
|
1003
|
+
A schema is data, so it travels whole. It carries no function, nothing is dropped on the way, and
|
|
1004
|
+
`serializeTable` and `parseTable` are the two ends of that trip.
|
|
1005
|
+
|
|
1006
|
+
```ts
|
|
1007
|
+
import { parseTable, serializeTable } from '@orkestrel/table'
|
|
1008
|
+
import type { TableSchema } from '@orkestrel/table'
|
|
1009
|
+
|
|
1010
|
+
const schema: TableSchema = {
|
|
1011
|
+
name: 'people',
|
|
1012
|
+
label: 'People',
|
|
1013
|
+
key: 'id',
|
|
1014
|
+
columns: [
|
|
1015
|
+
{ cell: 'text', key: 'id', label: 'Reference' },
|
|
1016
|
+
{ cell: 'number', key: 'age', label: 'Age', meta: { align: 'right' } },
|
|
1017
|
+
],
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const wire = JSON.stringify(serializeTable(schema))
|
|
1021
|
+
const received = parseTable(JSON.parse(wire))
|
|
1022
|
+
|
|
1023
|
+
JSON.stringify(serializeTable(received ?? schema)) === wire // true
|
|
1024
|
+
parseTable({ key: 'id', columns: 'not a list' }) // undefined
|
|
1025
|
+
parseTable({ columns: [{ cell: 'text', key: 'id' }] }) // undefined — no `key`
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
The round trip is byte-stable because both projections fix the order they emit in: `serializeTable`
|
|
1029
|
+
writes a schema's members in declaration order and a column's in the order the contract declares
|
|
1030
|
+
them, and `serializeRows` writes each row's cells in the schema's column order. Ordering is what
|
|
1031
|
+
turns "the same data" into "the same bytes", and it is the only reason a caller can compare two wire
|
|
1032
|
+
forms with `===` instead of walking them.
|
|
1033
|
+
|
|
1034
|
+
That is canonicalization, not preservation. A projection reorders whatever it was handed: an object
|
|
1035
|
+
written `columns` before `key` comes back with `key` before `columns`, because declaration order is
|
|
1036
|
+
the order the contract declares and not the order the caller typed. So the bytes settle at the first
|
|
1037
|
+
projection, and every projection after it — of that schema, or of what `parseTable` returned from
|
|
1038
|
+
those bytes — reproduces them exactly. Compare two wire forms, never a wire form against arbitrary
|
|
1039
|
+
incoming bytes.
|
|
1040
|
+
|
|
1041
|
+
Rows travel too, and they arrive as strings far more often than not — a query string, a form post, a
|
|
1042
|
+
CSV cell. `parseRows` coerces a numeric string into a `number` for a `number` column, and `'true'` or
|
|
1043
|
+
`'false'` into a boolean for a `flag` column, and nothing else. Every other value must already have
|
|
1044
|
+
its column's shape.
|
|
1045
|
+
|
|
1046
|
+
`parseRows` is strict in every other direction. It reads rows against the schema, so a key the
|
|
1047
|
+
schema does not declare refuses the whole payload, a cell its column cannot hold refuses the whole
|
|
1048
|
+
payload, and so does a row with no usable identity or an identity another row already used. A JSON
|
|
1049
|
+
`null` is a cell no column can hold, and it is the refusal a wire producer meets most often: absence
|
|
1050
|
+
here is an absent key, so a producer writing `null` for an empty field drops the key instead. There is
|
|
1051
|
+
no partial result, because a half-accepted row set is worse than a rejected one — and the door where
|
|
1052
|
+
identity is checked is this one, so nothing that reaches the table has to be checked for it twice.
|
|
1053
|
+
|
|
1054
|
+
```ts
|
|
1055
|
+
import { parseRows, serializeRows } from '@orkestrel/table'
|
|
1056
|
+
import type { TableSchema } from '@orkestrel/table'
|
|
1057
|
+
|
|
1058
|
+
const schema: TableSchema = {
|
|
1059
|
+
key: 'id',
|
|
1060
|
+
columns: [
|
|
1061
|
+
{ cell: 'text', key: 'id' },
|
|
1062
|
+
{ cell: 'number', key: 'age' },
|
|
1063
|
+
{ cell: 'flag', key: 'active' },
|
|
1064
|
+
],
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
parseRows(schema, [{ id: '1', age: '36', active: 'true' }]) // [{ id: '1', age: 36, active: true }]
|
|
1068
|
+
parseRows(schema, [{ id: '1', age: 'old' }]) // undefined — not a number
|
|
1069
|
+
parseRows(schema, [{ id: '1', age: null }]) // undefined — JSON's null is not an absent cell
|
|
1070
|
+
parseRows(schema, [{ id: '1' }, { id: '1' }]) // undefined — one identity, twice
|
|
1071
|
+
parseRows(schema, [{ id: '1', colour: 'red' }]) // undefined — no such column
|
|
1072
|
+
serializeRows(schema, [{ age: 36, id: '1' }]) // [{ id: '1', age: 36 }] — schema column order
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
**What never travels is everything that is not the document.** `TableOptions` does not: `on`,
|
|
1076
|
+
`error`, `comparators`, and `matchers` are functions or hold them, and a function has no wire form.
|
|
1077
|
+
Neither does live state — the sort terms, the filters, the picked and opened keys, and the page are
|
|
1078
|
+
the lens a session is looking through, not the data it is looking at. A host that wants to persist a
|
|
1079
|
+
lens reads `sort.orders()`, `filter.filters()`, `selection.keys`, and `pagination.page` and stores
|
|
1080
|
+
them at its own door, in its own format. This package ships no parser for them, because it would be
|
|
1081
|
+
a parser for the host's decision rather than for the table's document.
|
|
1082
|
+
|
|
1083
|
+
The guards are the same boundary read one value at a time, and every one of them is total.
|
|
1084
|
+
|
|
1085
|
+
```ts
|
|
1086
|
+
import {
|
|
1087
|
+
isColumnCell,
|
|
1088
|
+
isColumnChoice,
|
|
1089
|
+
isTableCell,
|
|
1090
|
+
isTableColumn,
|
|
1091
|
+
isTableRow,
|
|
1092
|
+
isTableSchema,
|
|
1093
|
+
} from '@orkestrel/table'
|
|
1094
|
+
|
|
1095
|
+
isColumnCell('choice') // true
|
|
1096
|
+
isColumnCell('date') // false — a date is `text` holding an ISO string
|
|
1097
|
+
isTableCell(36) // true
|
|
1098
|
+
isTableCell(null) // false — absence is an absent key, never null
|
|
1099
|
+
isTableRow({ id: '1', age: 36 }) // true
|
|
1100
|
+
isTableRow({ id: ['1'] }) // false
|
|
1101
|
+
isColumnChoice({ value: 'a', label: 'A' }) // true
|
|
1102
|
+
isColumnChoice({ value: 'a', label: 'A', colour: 'red' }) // false — an unknown member refuses it
|
|
1103
|
+
isTableColumn({ cell: 'choice', key: 'status', choices: [] }) // true — structure only
|
|
1104
|
+
isTableSchema({ key: 'id', columns: [{ cell: 'text', key: 'id' }] }) // true
|
|
1105
|
+
isTableSchema({ columns: [] }) // false — `key` is required
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
The schema guards are where the boundary is drawn twice, because a schema can be the right shape
|
|
1109
|
+
and still be a table nobody could open. `isStructuralTableSchema` answers the shape;
|
|
1110
|
+
`isTableSchema` answers the shape and the audit together, and it is the single-call guard consumers
|
|
1111
|
+
and parsers read. The constructor reads `isStructuralTableSchema` and runs `auditTable` once so its
|
|
1112
|
+
`SCHEMA` message retains the diagnostics.
|
|
1113
|
+
|
|
1114
|
+
```ts
|
|
1115
|
+
import { isStructuralTableSchema, isTableSchema, parseTable } from '@orkestrel/table'
|
|
1116
|
+
|
|
1117
|
+
const unsound = { key: 'missing', columns: [{ cell: 'text', key: 'id' }] }
|
|
1118
|
+
|
|
1119
|
+
isStructuralTableSchema(unsound) // true — every member is present and typed
|
|
1120
|
+
isTableSchema(unsound) // false — `key` names no declared column
|
|
1121
|
+
parseTable(unsound) // undefined — the parser refuses exactly what the guard refuses
|
|
1122
|
+
isStructuralTableSchema({ columns: [] }) // false — `key` is required by the shape itself
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
### Owning what arrives
|
|
1126
|
+
|
|
1127
|
+
The cloners are how a value stops being the caller's. The table clones the schema at construction and
|
|
1128
|
+
every row at admission, and it clones every row it hands back. They are exported because a consumer
|
|
1129
|
+
building its own row store needs the same guarantee.
|
|
1130
|
+
|
|
1131
|
+
Construction guards the schema it was handed, owns a copy, and then guards and audits that copy, so
|
|
1132
|
+
the schema a table opens against is the one it validated. A schema whose property reads and stored
|
|
1133
|
+
values disagree — a proxy answering a read with one value while holding another — is refused with
|
|
1134
|
+
`SCHEMA` instead of opened.
|
|
1135
|
+
|
|
1136
|
+
```ts
|
|
1137
|
+
import { cloneRow, cloneSchema } from '@orkestrel/table'
|
|
1138
|
+
|
|
1139
|
+
const row = { id: '1', age: 36 }
|
|
1140
|
+
const owned = cloneRow(row)
|
|
1141
|
+
|
|
1142
|
+
owned === row // false
|
|
1143
|
+
Object.isFrozen(owned) // true
|
|
1144
|
+
|
|
1145
|
+
Object.isFrozen(cloneSchema({ key: 'id', columns: [{ cell: 'text', key: 'id' }] })) // true
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
### Budgets
|
|
1149
|
+
|
|
1150
|
+
The budgets bound how much a schema can be, so a document that arrives from a wire cannot cost
|
|
1151
|
+
unbounded memory or unbounded scanning before anything decides to trust it. Every one is exported, so
|
|
1152
|
+
a host can check against the same number the package checks against.
|
|
1153
|
+
|
|
1154
|
+
| Constant | Value | Unit | Bounds |
|
|
1155
|
+
| -------------- | ------- | ----------------------- | ----------------------------------------- |
|
|
1156
|
+
| `COLUMN_LIMIT` | 256 | columns | One schema's `columns` |
|
|
1157
|
+
| `CHOICE_LIMIT` | 1024 | choices | One `choice` column's list |
|
|
1158
|
+
| `NAME_LIMIT` | 128 | UTF-16 code units | Each schema name and column key |
|
|
1159
|
+
| `STRING_LIMIT` | 65536 | UTF-16 code units | Any one retained string |
|
|
1160
|
+
| `TEXT_LIMIT` | 1048576 | UTF-16 code units | Every string one schema retains, together |
|
|
1161
|
+
| `NODE_LIMIT` | 16384 | records, arrays, leaves | Everything one schema retains, together |
|
|
1162
|
+
|
|
1163
|
+
They bind at the schema door and the value door, and which door a limit sits at is the whole story.
|
|
1164
|
+
|
|
1165
|
+
**The schema door reports.** `auditTable` counts columns, choices, names, strings, total text, and
|
|
1166
|
+
total nodes — `meta` included, since it is retained like everything else — and returns one human
|
|
1167
|
+
diagnostic per breach. `createTable` raises `SCHEMA` carrying them and `parseTable` refuses the
|
|
1168
|
+
schema, so no over-budget schema is ever held.
|
|
1169
|
+
|
|
1170
|
+
**The value door refuses.** `matchesCell` checks `STRING_LIMIT` on any string before it consults the
|
|
1171
|
+
column, so an over-long cell is refused before anything else looks at it. `rows.add`, `rows.update`,
|
|
1172
|
+
and a seeded row raise `CELL`; `parseRows` returns `undefined`.
|
|
1173
|
+
|
|
1174
|
+
`STRING_LIMIT` is the one that stands at each door, so no string this package retains is longer
|
|
1175
|
+
than 65536 code units whichever way it arrived.
|
|
1176
|
+
|
|
1177
|
+
The whole-schema ceilings are what make the arithmetic safe. Whatever the per-item limits admit,
|
|
1178
|
+
one audited schema retains at most 1048576 string code units and at most 16384 nodes, so the worst
|
|
1179
|
+
case is those ceilings rather than the product of the others.
|
|
1180
|
+
|
|
1181
|
+
Rows and the structural read stay unbounded, each for its own reason. **Rows are not budgeted**: a
|
|
1182
|
+
table legitimately holds a million of them, and a ceiling here would be product policy wearing a
|
|
1183
|
+
constant's name. And the structural **read** at the parse door is not bounded either — `parseTable`
|
|
1184
|
+
copies and guards every column that arrived before the audit sees one of them, so a payload four
|
|
1185
|
+
times over `COLUMN_LIMIT` is read four times over and then refused. Bound the size of a payload at
|
|
1186
|
+
the transport that delivers it, which is the only layer holding the bytes.
|
|
1187
|
+
|
|
1188
|
+
### Auditing a schema
|
|
1189
|
+
|
|
1190
|
+
`auditTable` is the semantic pass beyond structural validation. It reports the domain faults listed
|
|
1191
|
+
following and every budget breach stated earlier. The shape alone cannot see them, except an unownable `meta`,
|
|
1192
|
+
which the structural guard also refuses so the doors stay in agreement:
|
|
1193
|
+
|
|
1194
|
+
- `key` names no declared column.
|
|
1195
|
+
- `key` names a `number` or `flag` column, whose cells can never hold a string identity.
|
|
1196
|
+
- A column key is declared more than once.
|
|
1197
|
+
- A column key is empty.
|
|
1198
|
+
- A `choice` column offers the same value more than once.
|
|
1199
|
+
- A `choice` column offers no choice at all, so it is a column no cell could ever fill.
|
|
1200
|
+
- A column's `meta` cannot be owned as exact JSON.
|
|
1201
|
+
|
|
1202
|
+
The audit runs inside `createTable` and inside `parseTable`, so a consumer rarely calls it directly —
|
|
1203
|
+
but it is exported, because a schema editor wants the diagnostics before it constructs anything.
|
|
1204
|
+
`isTableSchema` is this list's emptiness over a structurally exact value, and nothing else, so the
|
|
1205
|
+
guard, the constructor, and the parser cannot disagree about which schemas a table can be opened
|
|
1206
|
+
against. The guard is the yes-or-no; the audit is why.
|
|
1207
|
+
|
|
1208
|
+
**It returns human diagnostics, not a machine contract.** Read them, show them, log them. Do not
|
|
1209
|
+
branch on their text or parse a column key out of them: the wording is free to change with the
|
|
1210
|
+
diagnostics, and only the emptiness of the list is a promise. It returns a string list rather than a
|
|
1211
|
+
`Result` for exactly that reason — a `Result` would dress a diagnostic list as an outcome and invite
|
|
1212
|
+
a consumer to treat one as the other. Where a machine outcome is what you need, use the guards, or
|
|
1213
|
+
use `parseTable` and read `undefined`.
|
|
1214
|
+
|
|
1215
|
+
```ts
|
|
1216
|
+
import { auditTable } from '@orkestrel/table'
|
|
1217
|
+
|
|
1218
|
+
auditTable({ key: 'ref', columns: [{ cell: 'text', key: 'id' }] })
|
|
1219
|
+
// ['schema key "ref" names no declared column']
|
|
1220
|
+
auditTable({ key: 'age', columns: [{ cell: 'number', key: 'age' }] })
|
|
1221
|
+
// ['schema key "age" names a number column, which holds no identity']
|
|
1222
|
+
auditTable({
|
|
1223
|
+
key: 'id',
|
|
1224
|
+
columns: [
|
|
1225
|
+
{ cell: 'text', key: 'id' },
|
|
1226
|
+
{ cell: 'text', key: 'id' },
|
|
1227
|
+
],
|
|
1228
|
+
})
|
|
1229
|
+
// ['column "id" is declared more than once']
|
|
1230
|
+
auditTable({ key: 'id', columns: [{ cell: 'text', key: 'id' }] }) // []
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
## Methods
|
|
1234
|
+
|
|
1235
|
+
The public methods of the behavioral interfaces, which their classes implement exactly and add
|
|
1236
|
+
nothing to. Every readonly data member stays in the `## Surface` rows stated earlier, in each
|
|
1237
|
+
interface's `Shape` cell, and is not repeated here.
|
|
1238
|
+
|
|
1239
|
+
Every other row in the Surface tables is a data shape, a union, a constant, a function, or an error
|
|
1240
|
+
class, so none of them carries a method table. `CellComparator` and `CellMatcher` are callable
|
|
1241
|
+
function types with one call signature and no named members.
|
|
1242
|
+
|
|
1243
|
+
Where a method takes no argument, one key, or a key list, that is one method with overloads rather
|
|
1244
|
+
than several methods. A list is checked in full before any of it moves, and the call returns
|
|
1245
|
+
`true` only when every name in it names what its manager addresses: a row the table holds for
|
|
1246
|
+
`rows`, `selection`, and `expansion`, and a column the schema declares for `sort` and `filter`. So
|
|
1247
|
+
`selection.clear('9')` is `false` for a key no row carries, while `sort.remove('age')` is `true` for
|
|
1248
|
+
a declared `age` column nothing was sorting by — the column exists, and afterwards nothing sorts by
|
|
1249
|
+
it either way.
|
|
1250
|
+
|
|
1251
|
+
#### `TableInterface`
|
|
1252
|
+
|
|
1253
|
+
| Method | Returns | Summary |
|
|
1254
|
+
| --------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
1255
|
+
| `clear` | `void` | Puts the table back the way it opened, holding nothing. Rows, sort, filter, selection, expansion, and the page all reset. |
|
|
1256
|
+
| `destroy` | `void` | Tears the table down. Idempotent; afterwards every write raises `DESTROYED` and every getter still answers. |
|
|
1257
|
+
|
|
1258
|
+
#### `RowManagerInterface`
|
|
1259
|
+
|
|
1260
|
+
| Method | Returns | Summary |
|
|
1261
|
+
| -------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
1262
|
+
| `row` | `TableRow` or `undefined` | Finds one row by key; `undefined` when the table holds no such key. |
|
|
1263
|
+
| `rows` | `readonly TableRow[]` | Reads every row the table holds, in its own order — unfiltered, unsorted, and unpaged. |
|
|
1264
|
+
| `add` | `void` | Takes in one row or several, appending them in the order given. Every row is checked before any is admitted. |
|
|
1265
|
+
| `update` | `boolean` | Writes over one row or several, each found by the key it carries. The cells given replace; the cells left out stay. |
|
|
1266
|
+
| `move` | `boolean` | Moves one row to another place in the table's own order, counted from zero and clamped to the rows that exist. |
|
|
1267
|
+
| `remove` | `void` or `boolean` | Takes out every row, one row, or several. Selection and expansion drop the keys of the rows that went. |
|
|
1268
|
+
|
|
1269
|
+
#### `SortManagerInterface`
|
|
1270
|
+
|
|
1271
|
+
| Method | Returns | Summary |
|
|
1272
|
+
| -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1273
|
+
| `order` | `TableOrder` or `undefined` | Finds one column's term; `undefined` when nothing sorts that column. |
|
|
1274
|
+
| `orders` | `readonly TableOrder[]` | Reads every term the table sorts by, first to last, in the order they decide. |
|
|
1275
|
+
| `set` | `void` | Sorts by one column or several. A term for a column already sorted replaces its direction in place; every other term joins the end. An undeclared column raises `COLUMN`. |
|
|
1276
|
+
| `remove` | `void` or `boolean` | Stops sorting by everything, by one column, or by several. An undeclared column returns `false` and stops nothing. |
|
|
1277
|
+
|
|
1278
|
+
#### `FilterManagerInterface`
|
|
1279
|
+
|
|
1280
|
+
| Method | Returns | Summary |
|
|
1281
|
+
| --------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1282
|
+
| `filter` | `TableFilter` or `undefined` | Finds one column's filter; `undefined` when nothing filters that column. |
|
|
1283
|
+
| `filters` | `readonly TableFilter[]` | Reads every filter the table keeps rows by, in the order they were set. |
|
|
1284
|
+
| `set` | `void` | Filters one column or several. A filter for a column already filtered replaces it; every other one joins the end. An undeclared column raises `COLUMN`, and a filter the column does not admit raises `CELL`. |
|
|
1285
|
+
| `remove` | `void` or `boolean` | Stops filtering everything, one column, or several. An undeclared column returns `false` and stops nothing. |
|
|
1286
|
+
|
|
1287
|
+
#### `SelectionManagerInterface`
|
|
1288
|
+
|
|
1289
|
+
| Method | Returns | Summary |
|
|
1290
|
+
| -------- | ------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
1291
|
+
| `select` | `void` or `boolean` | Picks every row the table holds, one row, or several. Every row, not every visible one. |
|
|
1292
|
+
| `clear` | `void` or `boolean` | Drops every pick, one pick, or several. A known key that was not picked still answers `true`. |
|
|
1293
|
+
| `toggle` | `boolean` | Picks one row, or drops it when it is already picked; over a list, turns each row around on its own. |
|
|
1294
|
+
|
|
1295
|
+
#### `ExpansionManagerInterface`
|
|
1296
|
+
|
|
1297
|
+
| Method | Returns | Summary |
|
|
1298
|
+
| -------- | ------------------- | --------------------------------------------------------------------------------------------------- |
|
|
1299
|
+
| `expand` | `void` or `boolean` | Opens every row the table holds, one row, or several. |
|
|
1300
|
+
| `clear` | `void` or `boolean` | Closes every row, one row, or several. A known key that was not open still answers `true`. |
|
|
1301
|
+
| `toggle` | `boolean` | Opens one row, or closes it when it is already open; over a list, turns each row around on its own. |
|
|
1302
|
+
|
|
1303
|
+
#### `PaginationManagerInterface`
|
|
1304
|
+
|
|
1305
|
+
| Method | Returns | Summary |
|
|
1306
|
+
| -------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
1307
|
+
| `move` | `void` | Shows another page, counted from one and clamped to the pages that exist. |
|
|
1308
|
+
| `resize` | `void` | Sets how many rows a page holds, keeping the first row the view was showing. Leave the argument out to stop paging. |
|
|
1309
|
+
|
|
1310
|
+
### Errors
|
|
1311
|
+
|
|
1312
|
+
`TableError` carries a machine-readable `code` and an optional structured `context`. Narrow a caught
|
|
1313
|
+
value with `isTableError` and branch on `code`; never match on message text.
|
|
1314
|
+
|
|
1315
|
+
| Code | Raised when |
|
|
1316
|
+
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1317
|
+
| `SCHEMA` | The schema is not a table schema, or `auditTable` found a domain fault or a budget breach — `createTable` and the `Table` constructor raise those. `serializeTable` and `cloneSchema` raise it at their own door for a `meta` no clone can own, which the guard and the audit refuse first for every schema whose reads are stable. |
|
|
1318
|
+
| `COLUMN` | A term or a filter names a column the schema does not declare. |
|
|
1319
|
+
| `KEY` | A row's identity is missing, unusable, already taken, or repeated inside one batch. |
|
|
1320
|
+
| `CELL` | A cell is one its column cannot hold, or a filter's operator or operand is one its column cannot take. |
|
|
1321
|
+
| `DESTROYED` | A write reached a table that has been torn down. |
|
|
1322
|
+
|
|
1323
|
+
```ts
|
|
1324
|
+
import { createTable, isTableError } from '@orkestrel/table'
|
|
1325
|
+
|
|
1326
|
+
try {
|
|
1327
|
+
createTable({ key: 'missing', columns: [{ cell: 'text', key: 'id' }] })
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
if (isTableError(error)) error.code // 'SCHEMA'
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const table = createTable({
|
|
1333
|
+
key: 'id',
|
|
1334
|
+
columns: [
|
|
1335
|
+
{ cell: 'text', key: 'id' },
|
|
1336
|
+
{ cell: 'number', key: 'age' },
|
|
1337
|
+
],
|
|
1338
|
+
})
|
|
1339
|
+
|
|
1340
|
+
try {
|
|
1341
|
+
table.sort.set({ column: 'nope', direction: 'ascending' })
|
|
1342
|
+
} catch (error) {
|
|
1343
|
+
if (isTableError(error)) error.code // 'COLUMN'
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
try {
|
|
1347
|
+
table.rows.add({ id: '1', age: 'twelve' })
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
if (isTableError(error)) error.code // 'CELL'
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
table.rows.rows().length // 0 — a refused write changed nothing
|
|
1353
|
+
```
|
|
1354
|
+
|
|
1355
|
+
`createTable` and `new Table(...)` are the same construction. Prefer the factory at a call site that
|
|
1356
|
+
only needs `TableInterface`; reach for the class where a class holds a table as its own field and
|
|
1357
|
+
wants the concrete type.
|
|
1358
|
+
|
|
1359
|
+
```ts
|
|
1360
|
+
import { Table } from '@orkestrel/table'
|
|
1361
|
+
|
|
1362
|
+
const table = new Table(
|
|
1363
|
+
{ key: 'id', columns: [{ cell: 'text', key: 'id' }] },
|
|
1364
|
+
{ rows: [{ id: '1' }] },
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
table.count // 1
|
|
1368
|
+
```
|
|
1369
|
+
|
|
1370
|
+
## Contract
|
|
1371
|
+
|
|
1372
|
+
These invariants hold across [`src/core`](../src/core) and this guide.
|
|
1373
|
+
|
|
1374
|
+
1. **Documented surface equals exported surface.** Every row in the `## Surface` tables is a real
|
|
1375
|
+
barrel export of `src/core`, and every barrel export is a row — both directions, exhaustively.
|
|
1376
|
+
The manager classes and the key-set shell are internal, so the parity suite's internal list names
|
|
1377
|
+
them and nothing else.
|
|
1378
|
+
2. **Documented methods equal interface methods.** Each `## Methods` table lists exactly its
|
|
1379
|
+
interface's call-signature members, and each class implements every one and adds no public
|
|
1380
|
+
behavior beyond them. Each interface has one table and one class.
|
|
1381
|
+
3. **Identity is a declared column's non-empty string cell.** `TableSchema.key` is required and must
|
|
1382
|
+
name a declared column. A row whose cell there is missing, empty, or not a string is refused with
|
|
1383
|
+
`KEY`, and so is a key the table already holds or a key repeated inside one batch. There is no
|
|
1384
|
+
default column, no positional fallback, and no key generation anywhere in this package.
|
|
1385
|
+
4. **A row is owned at admission and at every read.** The table clones and freezes each row it
|
|
1386
|
+
admits and each row it hands back, and clones and freezes the schema at construction. An edit to
|
|
1387
|
+
the object a caller passed changes nothing inside the table, and no getter returns a live internal
|
|
1388
|
+
reference.
|
|
1389
|
+
5. **A write is all-or-nothing, and it refuses by raising or by returning `false`.** `rows.add`,
|
|
1390
|
+
`rows.update`, `rows.remove`, `sort.set`, `sort.remove`, `filter.set`, `filter.remove`, and every
|
|
1391
|
+
0/1/N verb on selection and expansion check the whole argument before any of it lands. A bad
|
|
1392
|
+
value raises `KEY`, `CELL`, or `COLUMN`. A name that nothing answers to returns `false` and raises
|
|
1393
|
+
nothing — an unheld row key for `rows`, `selection`, and `expansion`, and an undeclared column for
|
|
1394
|
+
`sort.remove` and `filter.remove`. Either way the table is left exactly as it was.
|
|
1395
|
+
6. **`update` merges and cannot move a key.** The cells given replace the cells held and the cells
|
|
1396
|
+
left out stay as they are, and the row is found by the key it carries — so no sequence of
|
|
1397
|
+
`update` calls changes any row's identity.
|
|
1398
|
+
7. **The view is derived, never stored.** `view`, `count`, `pagination.offset`, and
|
|
1399
|
+
`pagination.count` are worked out on every read from the rows, the filters, the terms, and
|
|
1400
|
+
`page`. Nothing caches them, so none of them can disagree with what the table holds.
|
|
1401
|
+
8. **The lens applies in one order.** `view` is filtered, then sorted, then paged, always. A filter
|
|
1402
|
+
therefore decides `count` and the page count, and paging never reorders anything.
|
|
1403
|
+
9. **Sorting is stable and its terms are ordered.** The first term decides and each later term
|
|
1404
|
+
breaks the tie the ones before it left; rows no term separates keep the row store's own order.
|
|
1405
|
+
Setting a term for an already-sorted column replaces its direction in place and does not move it
|
|
1406
|
+
in the list. A `choice` column compares by the order its `choices` declares, a `flag` compares
|
|
1407
|
+
false before true, a `number` by magnitude, and a `text` lexically. An absent cell sorts before
|
|
1408
|
+
every present one in ascending order.
|
|
1409
|
+
10. **Filters are and-only, one per column.** The table holds at most one filter per column and
|
|
1410
|
+
keeps the rows every one of them accepts. There is no either-or composition and no nesting. An
|
|
1411
|
+
operator its column's cell does not admit, and an operand its column cannot hold, are both `CELL`
|
|
1412
|
+
refusals rather than empty results.
|
|
1413
|
+
11. **An override replaces one column's default and nothing else.** A `comparators` or `matchers`
|
|
1414
|
+
entry is consulted only for the column its key names, receives `undefined` for a row carrying no
|
|
1415
|
+
cell there, and never changes which columns exist. A comparator always describes ascending order
|
|
1416
|
+
and `TableDirection` is applied to its result. An entry naming an undeclared column is never
|
|
1417
|
+
consulted and is not an error.
|
|
1418
|
+
12. **Selection and expansion hold keys, and prune.** Both hold `TableKey` sets only, so a pick
|
|
1419
|
+
survives a sort, a filter, and a page turn; and a row leaving the table removes its key from
|
|
1420
|
+
both. Selecting with no argument picks every row the table holds, not every row in `view`.
|
|
1421
|
+
13. **Pagination is derived and clamps.** `page` is the only stored fact, counted from one;
|
|
1422
|
+
`offset` is `(page - 1) * limit` and zero when unpaged; `count` is `max(1, ceil(rows / limit))`
|
|
1423
|
+
and `1` when unpaged — so a paged table with no admitted rows holds one page, and that page's
|
|
1424
|
+
`view` is empty. A page beyond the last clamps to the last, including when a filter or a removal
|
|
1425
|
+
shrinks the rows underneath it. `resize` keeps the first row the view was showing.
|
|
1426
|
+
14. **Every event fires after commit, and a no-op is silent.** No listener sees a state the table has
|
|
1427
|
+
not finished writing, and a call that moves nothing announces nothing — the same sort term twice,
|
|
1428
|
+
a toggle back to where it was, a `move` to the index a row already occupies. When one call moves
|
|
1429
|
+
several things, the rows announce first and then the axes they disturbed, in the order `select`,
|
|
1430
|
+
`expand`, `paginate`. Seeded rows announce nothing at all.
|
|
1431
|
+
15. **`clear` is one announcement and `destroy` is idempotent.** `clear` emits `clear` alone,
|
|
1432
|
+
whatever it reset, so a reset of ten thousand rows is one event. `destroy` called twice does what
|
|
1433
|
+
calling it once did; afterwards every write raises `DESTROYED` while every getter still answers
|
|
1434
|
+
what the table last held, and `destroyed` reports the fact so a host need not catch to learn it.
|
|
1435
|
+
16. **Guards are total and parsers refuse.** No `is*` throws for any input — hostile prototype,
|
|
1436
|
+
symbol key, cycle, or depth. No `parse*` throws; each returns `undefined` on refusal. A
|
|
1437
|
+
guard-valid value is never refused by its parser, and every parsed result satisfies its guard.
|
|
1438
|
+
`isTableSchema` is therefore semantic and not merely structural: it is the exact shape plus an
|
|
1439
|
+
empty `auditTable`, which is exactly what `parseTable` and the `Table` constructor demand.
|
|
1440
|
+
`isStructuralTableSchema` is the shape alone, for a caller running the audit itself.
|
|
1441
|
+
17. **Only data crosses the wire, and the round trip is byte-stable.** `serializeTable` and
|
|
1442
|
+
`serializeRows` emit in canonical order — a schema's members in declaration order, a column's in
|
|
1443
|
+
the order the contract declares them, and a row's cells in the schema's column order. Incoming
|
|
1444
|
+
key order is canonicalized rather than preserved, so the bytes settle at the first projection
|
|
1445
|
+
and every projection after it reproduces them, including a projection of what `parseTable` or
|
|
1446
|
+
`parseRows` returned from those bytes. `meta` survives verbatim key for key. Options and live
|
|
1447
|
+
state have no projection at all: functions, sort terms, filters, picked keys, opened keys, and
|
|
1448
|
+
the page never travel.
|
|
1449
|
+
18. **Identity is checked at the parse door.** `parseRows` refuses the whole payload when a row has
|
|
1450
|
+
no usable identity, when two rows share one, when a cell's column cannot hold it, or when a key
|
|
1451
|
+
names no declared column. It coerces a numeric string for a `number` column and `'true'` /
|
|
1452
|
+
`'false'` for a `flag` column, and nothing else.
|
|
1453
|
+
19. **Every retained size is budgeted.** `auditTable` reports a breach of `COLUMN_LIMIT`,
|
|
1454
|
+
`CHOICE_LIMIT`, `NAME_LIMIT`, `STRING_LIMIT`, `TEXT_LIMIT`, or `NODE_LIMIT`, so `createTable`
|
|
1455
|
+
raises `SCHEMA` and `parseTable` refuses; `matchesCell` refuses a string breaching
|
|
1456
|
+
`STRING_LIMIT` before it consults the column, so a write raises `CELL` and `parseRows` returns
|
|
1457
|
+
`undefined`. `TEXT_LIMIT` and `NODE_LIMIT` are whole-schema ceilings, `meta` included, so the
|
|
1458
|
+
per-item limits never multiply. Row count is deliberately unbudgeted, and so is the structural
|
|
1459
|
+
read at the parse door, which is the transport's to bound.
|
|
1460
|
+
20. **`auditTable` returns diagnostics, not a contract.** The list's emptiness is the promise. The
|
|
1461
|
+
wording of its strings is not. Never parse them.
|
|
1462
|
+
21. **Temporal values are ISO text compared lexically, under one spelling.** A date, a time, and a
|
|
1463
|
+
timestamp are `text` cells, and lexical order is chronological order only where a column's
|
|
1464
|
+
values share one offset — normally UTC `Z` — one precision, and normalized midnight spelling.
|
|
1465
|
+
Mixed offsets order by spelling: `'2026-01-01T00:00:00+01:00'` sorts after
|
|
1466
|
+
`'2025-12-31T23:30:00Z'` and names an instant half an hour earlier. So does mixed precision,
|
|
1467
|
+
because `'09:00'` sorts before `'09:00:00'`. `2026-01-01T24:00:00Z` and
|
|
1468
|
+
`2026-01-02T00:00:00Z` name the same instant but sort differently, so midnight must use the
|
|
1469
|
+
next day's `00:00:00Z`. A filter's operands must match their cells the same way. No calendar is
|
|
1470
|
+
consulted either, so `'2026-02-31'` is an ordinary cell here. Normalizing the spelling is the
|
|
1471
|
+
host's, and a `CellComparator` covers a column that cannot.
|
|
1472
|
+
|
|
1473
|
+
## Concept inventory
|
|
1474
|
+
|
|
1475
|
+
What this package deliberately does not do, and where the work goes instead. Each line is a boundary
|
|
1476
|
+
taken on evidence, not an omission — so a reader can tell a boundary from a gap, and the next change
|
|
1477
|
+
knows what it is reopening. `Layer` names who owns the concept, and a row reading **seam** is one
|
|
1478
|
+
this package answers today through a mechanism it already exposes.
|
|
1479
|
+
|
|
1480
|
+
| Concept | Layer | Why it sits there |
|
|
1481
|
+
| ---------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1482
|
+
| Rendering | host | The table owns values, not pixels. It names no host type, so one table serves a browser, a terminal, a report, and an export equally — and the moment it drew one of them it would serve only that one. |
|
|
1483
|
+
| ARIA and roles | host | `label` and `help` are the strings an accessible grid needs. The roles, the ids, and their uniqueness belong to the layer that owns the elements. |
|
|
1484
|
+
| Keyboard and focus | host | Arrow keys, roving focus, and type-ahead are input gestures over drawn cells. The table has no cursor because it has no cells on a screen. |
|
|
1485
|
+
| Virtualization | host | Which rows are cheap to draw is a measurement of drawn things. `pagination` already gives a windowing host `offset` and `limit`, and a virtualizer that wants the whole list leaves the table unpaged and reads `view`, which is every admitted row in sort order. `rows()` is the store's own order and ignores the lens. |
|
|
1486
|
+
| Column resizing | host | A width is pixels. `meta` carries one when a host must ship it in the schema, and the table never reads it. |
|
|
1487
|
+
| Column reordering and hiding | seam | `columns` order is presentation order and `hidden` is the flag. Reordering is building the next schema, which is one line over the array a host already holds. |
|
|
1488
|
+
| Row drag-and-drop | seam | The gesture is the host's; the result is `rows.move(key, index)`, which is the one verb that writes the table's own order. |
|
|
1489
|
+
| Sticky columns and headers | host | Which columns stay put while the rest scroll is a layout decision about drawn elements. |
|
|
1490
|
+
| Or-filters and nested groups | out | v1 composes filters with **and** only, one per column, which is what a filter bar produces. The one thing it cannot express at all is the global search box that matches a term against every column: that is an or across columns, and a host wanting one narrows `rows()` with its own predicate and holds the result itself. Either-or turns a flat list into a tree and takes every guard, parser, and wire form with it. |
|
|
1491
|
+
| Grouping and aggregation | out | Group headers, subtotals, and rollups add a second row kind that is not a row, and a fold that is not a filter. `view` stays a flat list of the rows the table holds. |
|
|
1492
|
+
| Tree and hierarchical rows | out | A parent key would make `view` a traversal rather than a projection, and expansion would mean "show children" instead of "this row is open". Expansion here says only which rows are open. |
|
|
1493
|
+
| Editing transactions | out | A staged edit set with commit and rollback is a second store beside the row store, and two writers that can disagree. A host that needs one holds the pending values and calls `update` once. |
|
|
1494
|
+
| Undo and history | out | The table holds the rows now. A stack of everything before is a host concern with a host's retention policy, built over the same `update` and `remove` calls. |
|
|
1495
|
+
| Async data sources | host | Every read here is synchronous, so `view` is right the instant anything moves. Fetching, paging a server, and reconciling a response are the host's; it reads the lens and calls `add` or `clear`. |
|
|
1496
|
+
| Server-side paging | seam | `sort.orders()`, `filter.filters()`, `pagination.offset`, and `pagination.limit` are already the shape a query asks for, in the vocabulary `@orkestrel/database` uses. |
|
|
1497
|
+
| Row count budget | out | A table legitimately holds a million rows. The budgets bound one schema's declarations, which arrive from a wire; how many rows a host holds is the host's memory to spend. |
|
|
1498
|
+
| Parsing a lens off the wire | out | Sort terms, filters, and the picked keys are what a session is looking through, not the document. A host persisting them owns the format, so a parser here would parse the host's decision. |
|
|
1499
|
+
| Case folding and collation | seam | `contains` compares case-sensitively and `text` compares with the language's own string order. Locale is a decision this package cannot make for a host, so a `CellMatcher` or `CellComparator` makes it. |
|
|
1500
|
+
| A temporal cell | out | ISO text written to one canonical spelling per instant — one offset, one precision, and no alternate spelling of a given date or time — sorts chronologically already, so a temporal cell would add a variant that behaves exactly like `text` and a calendar to go with it. Normalizing to that one canonical representation is the host's, and a `CellComparator` covers a column that arrives mixed. |
|
|
1501
|
+
| Calendar validity | host | `'2026-02-31'` is lexically fine and not a real day. A date control refuses it before it arrives, and a domain that needs the check adds it at its own door. |
|
|
1502
|
+
| Number and date formatting | host | A `number` cell is a number and a date is its ISO string. Turning either into what a person reads is locale work at the point of drawing, and `meta` carries the hint when a schema must ship one. |
|
|
1503
|
+
| CSV and spreadsheet export | host | `view` or `rows()` plus `schema.columns` is the whole input an exporter needs, and the file format, encoding, and download belong to the host that has a filesystem or a browser. |
|
|
1504
|
+
| Cell-level selection | out | Selection holds row keys. A rectangular cell range is a second selection model with its own vocabulary, and no consumer has asked for one. |
|
|
1505
|
+
| Choice `meta` | out | `meta` is on `ColumnBase` alone, because a column carrier is what the first consumer asked for. The exact guard refuses it on `ColumnChoice` until one asks. |
|
|
1506
|
+
| Browser binding | `src/browser` | Binding a table to real elements — a grid, its headings, its scroll container — belongs in a future `src/browser`, taking a table and an element. Nothing renders in this round. |
|
|
1507
|
+
|
|
1508
|
+
## Tests
|
|
1509
|
+
|
|
1510
|
+
- [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ barrel bijection, the
|
|
1511
|
+
interface ↔ class method bijections, and the equality gate: every `Summary` cell against its
|
|
1512
|
+
declaration's description paragraph, the titled `Open a table` fence against the `@example` block
|
|
1513
|
+
of that title (pinned so the titled pair cannot be retired silently), and the README pitch against
|
|
1514
|
+
this guide's tagline. It also runs the preceding worked examples against the real source so a
|
|
1515
|
+
documented value that the code contradicts fails.
|
|
1516
|
+
- [`tests/src/core/Table.test.ts`](../tests/src/core/Table.test.ts) — construction, schema
|
|
1517
|
+
ownership, seeding, the derived `view` and `count`, emission order, `clear`, `destroy`, and writes
|
|
1518
|
+
after teardown.
|
|
1519
|
+
- [`tests/src/core/tables/RowManager.test.ts`](../tests/src/core/tables/RowManager.test.ts) —
|
|
1520
|
+
`row`, `rows`, `add`, `update`, `move`, `remove`, batch atomicity, and identity refusals.
|
|
1521
|
+
- [`tests/src/core/tables/SortManager.test.ts`](../tests/src/core/tables/SortManager.test.ts) —
|
|
1522
|
+
`order`, `orders`, `set`, `remove`, in-place replacement, stability, and comparator overrides.
|
|
1523
|
+
- [`tests/src/core/tables/FilterManager.test.ts`](../tests/src/core/tables/FilterManager.test.ts) —
|
|
1524
|
+
`filter`, `filters`, `set`, `remove`, and-composition, operator refusals, and matcher overrides.
|
|
1525
|
+
- [`tests/src/core/tables/SelectionManager.test.ts`](../tests/src/core/tables/SelectionManager.test.ts)
|
|
1526
|
+
— `select`, `clear`, `toggle`, the 0/1/N overloads, and pruning on removal.
|
|
1527
|
+
- [`tests/src/core/tables/ExpansionManager.test.ts`](../tests/src/core/tables/ExpansionManager.test.ts)
|
|
1528
|
+
— `expand`, `clear`, `toggle`, the 0/1/N overloads, and pruning on removal.
|
|
1529
|
+
- [`tests/src/core/tables/KeyManager.test.ts`](../tests/src/core/tables/KeyManager.test.ts) — the
|
|
1530
|
+
0/1/N forms, the unknown-key refusal, the announcement only on a move, the owned key set, and the
|
|
1531
|
+
gate throw passed through.
|
|
1532
|
+
- [`tests/src/core/tables/PaginationManager.test.ts`](../tests/src/core/tables/PaginationManager.test.ts)
|
|
1533
|
+
— `page`, `limit`, `offset`, `count`, `move`, `resize`, clamping, and the unpaged table.
|
|
1534
|
+
- [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) — `extractColumn`,
|
|
1535
|
+
`extractKey`, `computeKeys`, `mergeTerms`, `removeTerms`, `matchesTerms`, `matchesCell`,
|
|
1536
|
+
`compareCells`, `admitsFilter`, `matchesFilter`, `filterRows`, `sortRows`, `auditTable`,
|
|
1537
|
+
`serializeTable`, `serializeRows`, and the budgets.
|
|
1538
|
+
- [`tests/src/core/validators.test.ts`](../tests/src/core/validators.test.ts) — every guard against
|
|
1539
|
+
valid, off-shape, and hostile input, plus guard/parser soundness in both directions.
|
|
1540
|
+
- [`tests/src/core/parsers.test.ts`](../tests/src/core/parsers.test.ts) — `parseTable`, `parseRows`,
|
|
1541
|
+
the coercions, identity at the parse door, and the canonical byte-stable round trip.
|
|
1542
|
+
- [`tests/src/core/cloners.test.ts`](../tests/src/core/cloners.test.ts) — every clone is owned,
|
|
1543
|
+
frozen, and deep enough that no caller reference survives.
|
|
1544
|
+
- [`tests/src/core/constants.test.ts`](../tests/src/core/constants.test.ts) — the cell registry and
|
|
1545
|
+
each budget's value and unit.
|
|
1546
|
+
- [`tests/src/core/errors.test.ts`](../tests/src/core/errors.test.ts) — `TableError`'s `code` and
|
|
1547
|
+
`context`, and `isTableError` narrowing.
|
|
1548
|
+
- [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) — `createTable` returns
|
|
1549
|
+
a working `TableInterface`.
|
|
1550
|
+
- [`tests/src/core/index.test.ts`](../tests/src/core/index.test.ts) — the barrel resolves every
|
|
1551
|
+
documented export.
|
|
1552
|
+
|
|
1553
|
+
## See also
|
|
1554
|
+
|
|
1555
|
+
- [`AGENTS.md`](../AGENTS.md) — the coding contract this package is written against.
|
|
1556
|
+
- [`README.md`](README.md) — the guides index.
|