You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A schema field type for embedded structured tabular data — each row is a set of typed cells, the whole table is a single field value. Designed for repeating data that lives inside a note (recipe ingredients, invoice line items, workout sets, pricing tiers), not for rows that deserve to be standalone entities.
1. Rationale
Krill Notes already supports repeating data via child notes. That works well when rows are real entities — tasks under a project, observations under a survey. It does not work well when the data is intrinsically part of the parent: an ingredient outside a recipe has no meaning, does not deserve a UUID, should not clutter the tree, and does not want its own schema entry.
The table field type fills that gap. It stores structured multi-column, multi-row data as a single field value, with per-column type definitions, row-level validation, and whole-table validation. It follows the principle “rows are cells, not notes”.
2. When to Use
Use a table field when:
Rows have no meaningful existence outside the parent note.
Row count is small and bounded (rule of thumb: < 50 rows, typically < 20).
Concurrent multi-peer editing of the same table is rare (LWW at the field level is acceptable — see §7).
You never need to link to an individual row from another note.
Use child notes instead when:
Rows are real entities with their own identity (tasks, contacts, observations).
Rows need to be linkable, searchable by type, or independently permissioned.
Row count is large or unbounded.
Multiple peers routinely edit rows of the same parent concurrently.
3. Schema Declaration
A table field is declared like any other field, with type: "table" and a mandatory columns array.
If true, render a compact summary (row count + first column preview) in hover tooltips
required: true and min_rows: 1 are equivalent; if both are present and disagree, min_rows wins and a schema load warning is emitted.
4. Column Specification
Each entry in columns is a field definition with the same shape as a top-level field — with two constraints:
All top-level field types are allowed except table. No nested tables. If you need nesting, use child notes or reconsider whether the data really belongs embedded.
note_link columns fully support the filter attribute from the filtered-reference work. This is the intended pattern for recipe ingredients that should reference a canonical Ingredient note: #{ name: "substance", type: "note_link", target_schema: "Ingredient", filter: #{ archived: false } }.
Per-Column Options
All standard field options apply per column:
Option
Notes
name
Required. Must be unique within the table. Used as the CBOR map key (or integer index in compact mode — see §6).
label
Optional display label; defaults to name.
type
Required. Any top-level type except table.
required
Per-cell; rejected if empty.
default
Value used for new rows.
options
For select columns.
max
For rating columns.
target_schema
For note_link columns.
filter
For note_link columns.
allowed_types
For file columns.
can_edit
Per-cell read-only flag. Rare but useful for computed columns populated by validate_row.
Disallowed Column Keys
Key
Reason
show_on_hover
Hover applies at the note level; individual cells don’t hover.
can_view
Columns are always visible when the table is visible. Use can_view: false on the whole table to hide it.
5. Validation
Validation fires in a fixed order during the SaveTransaction commit for the parent note:
validate_row closure. Receives a row map; may call reject(column_name, message) or reject(message). Invoked once per row; the row index is available as row._index (read-only synthetic key).
min_rows / max_rows check.
validate_table closure. Receives the full rows array. Can call reject(message) for table-level errors.
Any rejection aborts the commit for the parent note, consistent with existing SaveTransaction semantics.
Example
validate_row: |row| {
if row.amount <= 0 {
reject("amount", "must be greater than zero");
}
if row.unit == "piece" && row.amount != row.amount.to_int() {
reject("amount", "piece counts must be whole numbers");
}
},
validate_table: |rows| {
let substances = rows.map(|r| r.substance.to_lower());
if substances.len() != substances.dedup().len() {
reject("duplicate substances are not allowed");
}
},
6. Storage Format
In the Note
A table field value is a CBOR array of maps. Each map is a row; keys are column names (or column indexes in compact mode); values follow the same CBOR encoding rules as top-level field values.
When the parent note is encoded in compact-key mode (sensor provisioning etc.), table columns may declare an index property exactly like top-level fields. Compact mode then uses integer keys for the column map:
Column indexes are scoped to the column set of a single table field; they do not collide with top-level field indexes. Indexes are unsigned integers, 1–255 recommended, zero reserved. The same stability rules as top-level field indexes apply (MUST NOT change across schema versions; adding new indexes is safe).
Null Cells
Empty/unset cells are encoded as CBOR null. Omitting a key is equivalent to null. Writers SHOULD include all column keys for clarity; readers MUST tolerate both forms.
Unknown Columns
If a row contains a key that is not in the current schema’s column set (e.g. because the schema was downgraded or a peer has an older schema version), the value is preserved on round-trip but ignored for validation and display. This mirrors the existing behaviour for unknown fields on notes.
7. Operation Semantics
No new payload type. A table field update is a standard UpdateField operation whose value is the full CBOR array described in §6. Writing a table means replacing the entire table value.
Consequences
Last-writer-wins at the field level. If peer A appends row 4 and peer B appends row 5 concurrently, whichever operation has the later HLC wins and the other side’s addition is lost. This is acceptable for embedded tables (see §2); it is also the same model as every other field in Krill Notes today. Document it clearly in user-facing help.
Every cell edit rewrites and re-syncs the whole table. Fine for 5–20 rows, wasteful at 500. Schema authors should choose child notes before they hit that ceiling.
No per-row operations, no per-cell operations. This is a deliberate simplification. Revisiting this would require assigning stable row identifiers and introducing SetTableRow / DeleteTableRow payload types — out of scope for v1 (see §12).
Operation Log Display
When the user inspects an UpdateField op for a table field, the operations log viewer should render a diff summary (e.g. “ingredients: 3 rows → 4 rows (+1 butter)”) rather than a raw CBOR dump. Row-level diff is best-effort; if rows have no stable identifier, a simple textual diff of the rendered table is acceptable.
8. Rendering and Editor
Edit Mode
The detail panel renders the table field as an inline grid:
One row per table row; one column per declared column; a trailing “delete row” control per row.
Each cell reuses the existing field editor for its column type (text input, number spinner, date picker, select dropdown, note_link picker including the filter, etc.).
An “Add row” control below the grid appends a new row; new rows are populated from each column’s default if set, otherwise empty.
Row reordering is supported via drag handle on each row; the new order is the authoritative order.
Per-cell validation errors are rendered under the cell; row/table errors appear above the grid.
View Mode
Read-only rendering of the same grid, without editor chrome.
Responsiveness
For tables wider than the available panel width, horizontal scroll within the table region is acceptable. Do not reflow columns into cards — the tabular structure is the point.
Keyboard
Tab / Shift-Tab move between cells.
Enter in the last cell of the last row adds a new row.
Down-arrow from a cell moves to the same column in the next row (adding a row if at the end is opt-in per future config; not in v1).
9. Query and Display Helpers
Reading in Rhai
A table field value is exposed to Rhai scripts as a plain array of maps:
let rows = note.fields["ingredients"];
for row in rows {
print(`${row.amount}${row.unit} ${row.substance}`);
}
Display Helper
Add one display helper to match the existing set:
Function
Purpose
display_table_field(note, field_name)
Render the table field as an HTML table using column labels as headers, with per-column-type cell formatting (stars for rating columns, mailto links for email columns, note links for note_link columns, etc.).
This is a convenience around the existing table(headers, rows) helper; authors who want custom rendering can still iterate the array manually.
10. Migration
Schema migrations on a note type may add, remove, or modify table columns. Existing rows migrate as follows:
New column added. Rows gain the column with its default value if specified, otherwise null.
Column removed. The key is dropped from each row. The value is preserved in the pre-migration snapshot kept for undo, but not in the live data.
Column renamed. Must be handled by a migrate_row closure (see below); renames are not automatic.
Column type changed. Must be handled by a migrate_row closure. No automatic type coercion.
Column index changed. Forbidden — same rule as top-level field indexes. Schema load fails with a clear error.
migrate_row Closure
Schema migrations may optionally declare a migrate_row closure per table field, invoked for each row during migration:
migrate: #{
2: |note| {
// v1→v2: rename "qty" to "amount" in the ingredients table
note.fields["ingredients"] = note.fields["ingredients"].map(|row| {
row.amount = row.qty;
row.remove("qty");
row
});
},
},
No special syntax is added for this in v1; it uses the existing note-level migrate closure. A future iteration may add a dedicated migrate_row per-table entry if this turns out to be common.
11. Edge Cases and Constraints
Case
Behaviour
Empty table, required: false
Stored as empty array []. Valid.
Empty table, required: true or min_rows >= 1
Rejected.
Duplicate column names in schema
Schema load fails.
Column type: "table" in schema
Schema load fails with a clear error.
Row with a column value of the wrong type
Cell-level rejection at validation. No silent coercion.
Row with extra unknown keys
Preserved on round-trip, ignored for validation.
Row count exceeds max_rows
Rejected at validation.
validate_row called on an empty table
Not called. Zero rows means zero invocations.
validate_table called on an empty table
Called with empty array, allowing “require at least one” to be written in user code if min_rows is insufficient.
Schema introduces a required column to an existing table
Migration either supplies a default or runs a migrate_row that sets the value; otherwise post-migration validation will fail on the first save.
12. Out of Scope for v1
These are deliberate omissions, not oversights:
Nested tables. No table columns inside a table.
Row-level operations. No per-row add/update/delete operations in the log. Whole-table LWW only.
Stable row identifiers. Rows are identified by array position. Revisit if we ever want row-level sync granularity or cross-note row references.
Row-level permissions. Permissions apply at the note level. A table is either entirely editable by a principal or entirely not.
Computed columns. No formula type. Computed values can be derived in validate_row by setting another cell, or by rendering in views.
Column-level schema versioning independent of the note schema. Column changes ride on the note schema’s version.
Sort / filter UI in view mode. Row order is authoritative; filtering is the schema author’s job in custom views.
13. Example: Recipe Ingredients
A complete Recipe schema using the new table field:
schema("Recipe", #{
version: 2, // v2 adds the ingredients table; v1 had a textarea
fields: [
#{ name: "servings", type: "number", required: true, default: 4 },
#{ name: "prep_minutes", type: "number" },
#{ name: "cook_minutes", type: "number" },
#{ name: "ingredients",
type: "table",
required: true,
min_rows: 1,
max_rows: 50,
columns: [
#{ name: "substance", type: "text", required: true },
#{ name: "amount", type: "number", required: true },
#{ name: "unit", type: "select",
options: ["g", "kg", "ml", "l", "cup", "tbsp", "tsp", "pinch", "piece"],
required: true },
#{ name: "notes", type: "text" },
],
validate_row: |row| {
if row.amount <= 0 {
reject("amount", "must be positive");
}
if row.unit == "piece" && row.amount != row.amount.to_int() {
reject("amount", "piece counts must be whole numbers");
}
},
validate_table: |rows| {
let names = rows.map(|r| r.substance.to_lower());
if names.len() != names.dedup().len() {
reject("ingredients must be unique (case-insensitive)");
}
},
},
#{ name: "method", type: "textarea", required: true },
],
field_groups: [
#{ label: "Summary", fields: ["servings", "prep_minutes", "cook_minutes"] },
#{ label: "Ingredients", fields: ["ingredients"] },
#{ label: "Method", fields: ["method"] },
],
migrate: #{
2: |note| {
// v1 had a single `ingredients` textarea with free text.
// v2 replaces it with a structured table; best effort is to
// start with an empty table and leave the old text in `method`
// for the user to port manually.
let old_text = note.fields["ingredients"];
note.fields["ingredients"] = [];
if old_text != () && old_text != "" {
note.fields["method"] =
`Ingredients (from old version):\n${old_text}\n\n${note.fields["method"]}`;
}
},
},
});
14. Implementation Checklist
krillnotes-core (Rust)
AddFieldType::Table with columns, min_rows, max_rows, validate_row, validate_table parsed from the schema map.
Implement column parser: reject nested tables, reject duplicate column names, validate index stability across schema versions.
Extend the field value CBOR codec to handle array-of-maps for table values, in both text-key and compact-key modes.
Extend the SaveTransaction validator to run per-cell, per-row,min/max_rows, then per-table validation in that order.
Surface per-cell errors with keys of the form<table_name>[<row_index>].<column_name>.
Expose the table value to the Rhai engine asArray<Map>; the synthetic _index key is populated when Rhai invokes validate_row.
Add migration support: field additions with defaults, field removals,migrate_row-style transformations via the existing migrate closure.
Add adisplay_table_field(note, field_name) helper that emits the rendered HTML.
Recipe example end-to-end: create, edit, add/remove rows, migrate from v1 textarea.
15. Open Questions
These are worth deciding during implementation rather than locking now:
Hover summary content. Is “3 rows: flour, sugar, butter” enough, or do we want a mini-table? My vote: text summary only, to keep hover tooltips compact.
Clipboard copy/paste. Should the grid support copy/paste from spreadsheet tools (TSV on clipboard)? Useful but non-trivial; defer unless it’s easy.
Default column width. Auto-size based on column type, or uniform? Suggest: narrow for number/boolean/rating, wider for text/textarea, fill remaining for the last text column.
Error on unknown column keys from the UI. Core preserves them, but should the editor strip them on save? I’d say yes — the editor only writes declared columns.
Table Field Type Specification
1. Rationale
Krill Notes already supports repeating data via child notes. That works well when rows are real entities — tasks under a project, observations under a survey. It does not work well when the data is intrinsically part of the parent: an ingredient outside a recipe has no meaning, does not deserve a UUID, should not clutter the tree, and does not want its own schema entry.
The
tablefield type fills that gap. It stores structured multi-column, multi-row data as a single field value, with per-column type definitions, row-level validation, and whole-table validation. It follows the principle “rows are cells, not notes”.2. When to Use
Use a
tablefield when:Use child notes instead when:
3. Schema Declaration
A table field is declared like any other field, with
type: "table"and a mandatorycolumnsarray.Table-Level Options
columnsmin_rows0max_rowsvalidate_rowvalidate_tablerequiredfalsetrue, equivalent tomin_rows: 1can_viewtruecan_edittrueshow_on_hoverfalsetrue, render a compact summary (row count + first column preview) in hover tooltipsrequired: trueandmin_rows: 1are equivalent; if both are present and disagree,min_rowswins and a schema load warning is emitted.4. Column Specification
Each entry in
columnsis a field definition with the same shape as a top-level field — with two constraints:table. No nested tables. If you need nesting, use child notes or reconsider whether the data really belongs embedded.note_linkcolumns fully support the filter attribute from the filtered-reference work. This is the intended pattern for recipe ingredients that should reference a canonicalIngredientnote:#{ name: "substance", type: "note_link", target_schema: "Ingredient", filter: #{ archived: false } }.Per-Column Options
All standard field options apply per column:
namelabelname.typetable.requireddefaultoptionsselectcolumns.maxratingcolumns.target_schemanote_linkcolumns.filternote_linkcolumns.allowed_typesfilecolumns.can_editvalidate_row.Disallowed Column Keys
show_on_hovercan_viewcan_view: falseon the whole table to hide it.5. Validation
Validation fires in a fixed order during the SaveTransaction commit for the parent note:
requiredcheck, column-specific checks (select option membership, file MIME type, note_link filter match). Produces field-specific rejections keyed to<table_name>[<row_index>].<column_name>.validate_rowclosure. Receives a row map; may callreject(column_name, message)orreject(message). Invoked once per row; the row index is available asrow._index(read-only synthetic key).min_rows/max_rowscheck.validate_tableclosure. Receives the fullrowsarray. Can callreject(message)for table-level errors.Any rejection aborts the commit for the parent note, consistent with existing SaveTransaction semantics.
Example
6. Storage Format
In the Note
A table field value is a CBOR array of maps. Each map is a row; keys are column names (or column indexes in compact mode); values follow the same CBOR encoding rules as top-level field values.
Compact Mode
When the parent note is encoded in compact-key mode (sensor provisioning etc.), table columns may declare an
indexproperty exactly like top-level fields. Compact mode then uses integer keys for the column map:Column indexes are scoped to the column set of a single table field; they do not collide with top-level field indexes. Indexes are unsigned integers, 1–255 recommended, zero reserved. The same stability rules as top-level field indexes apply (MUST NOT change across schema versions; adding new indexes is safe).
Null Cells
Empty/unset cells are encoded as CBOR
null. Omitting a key is equivalent tonull. Writers SHOULD include all column keys for clarity; readers MUST tolerate both forms.Unknown Columns
If a row contains a key that is not in the current schema’s column set (e.g. because the schema was downgraded or a peer has an older schema version), the value is preserved on round-trip but ignored for validation and display. This mirrors the existing behaviour for unknown fields on notes.
7. Operation Semantics
No new payload type. A table field update is a standard
UpdateFieldoperation whosevalueis the full CBOR array described in §6. Writing a table means replacing the entire table value.Consequences
SetTableRow/DeleteTableRowpayload types — out of scope for v1 (see §12).Operation Log Display
When the user inspects an
UpdateFieldop for a table field, the operations log viewer should render a diff summary (e.g. “ingredients: 3 rows → 4 rows (+1 butter)”) rather than a raw CBOR dump. Row-level diff is best-effort; if rows have no stable identifier, a simple textual diff of the rendered table is acceptable.8. Rendering and Editor
Edit Mode
The detail panel renders the table field as an inline grid:
defaultif set, otherwise empty.View Mode
Read-only rendering of the same grid, without editor chrome.
Responsiveness
For tables wider than the available panel width, horizontal scroll within the table region is acceptable. Do not reflow columns into cards — the tabular structure is the point.
Keyboard
9. Query and Display Helpers
Reading in Rhai
A table field value is exposed to Rhai scripts as a plain array of maps:
Display Helper
Add one display helper to match the existing set:
display_table_field(note, field_name)This is a convenience around the existing
table(headers, rows)helper; authors who want custom rendering can still iterate the array manually.10. Migration
Schema migrations on a note type may add, remove, or modify table columns. Existing rows migrate as follows:
defaultvalue if specified, otherwisenull.migrate_rowclosure (see below); renames are not automatic.migrate_rowclosure. No automatic type coercion.migrate_rowClosureSchema migrations may optionally declare a
migrate_rowclosure per table field, invoked for each row during migration:No special syntax is added for this in v1; it uses the existing note-level
migrateclosure. A future iteration may add a dedicatedmigrate_rowper-table entry if this turns out to be common.11. Edge Cases and Constraints
required: false[]. Valid.required: trueormin_rows >= 1type: "table"in schemamax_rowsvalidate_rowcalled on an empty tablevalidate_tablecalled on an empty tablemin_rowsis insufficient.defaultor runs amigrate_rowthat sets the value; otherwise post-migration validation will fail on the first save.12. Out of Scope for v1
These are deliberate omissions, not oversights:
tablecolumns inside atable.formulatype. Computed values can be derived invalidate_rowby setting another cell, or by rendering in views.13. Example: Recipe Ingredients
A complete
Recipeschema using the new table field:14. Implementation Checklist
krillnotes-core(Rust)FieldType::Tablewithcolumns,min_rows,max_rows,validate_row,validate_tableparsed from the schema map.min/max_rows, then per-table validation in that order.<table_name>[<row_index>].<column_name>.Array<Map>; the synthetic_indexkey is populated when Rhai invokesvalidate_row.migrate_row-style transformations via the existingmigrateclosure.display_table_field(note, field_name)helper that emits the rendered HTML.Frontend (React / TypeScript)
TableFieldEditorcomponent: inline grid, per-cell editor dispatch by column type, add/delete/reorder row controls.UpdateFieldop with the full new array.note_linkcolumn picker reuses the existing note_link picker including the filter.Operations Log Viewer
UpdateFieldops on table fields with a row-count and row-diff summary instead of a raw CBOR dump.Documentation
type: "table"row to the field type table inthe-schema.md.use-cases.mdunder the Recipe example pointing at the new field type.krillnotes-2-spec.mdwith the CBOR array-of-maps value form and the columnindexnote.Tests
migrate.15. Open Questions
These are worth deciding during implementation rather than locking now:
Changelog: v0.1 — initial specification, 2026-04-22.