Skip to content

Feature: New Table field type #153

@careck

Description

@careck

Table Field Type Specification

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.

#{
    name: "ingredients",
    type: "table",
    required: true,
    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 },
    ],
    min_rows: 1,
    max_rows: 50,
    validate_row:   |row|  { /* ... */ },
    validate_table: |rows| { /* ... */ },
}

Table-Level Options

Option Type Default Purpose
columns array required Ordered column definitions (see §4)
min_rows integer 0 Reject save if fewer than N rows
max_rows integer unlimited Reject save if more than N rows
validate_row closure none Per-row validation (see §5)
validate_table closure none Whole-table validation (see §5)
required bool false If true, equivalent to min_rows: 1
can_view bool true Inherited from common field options
can_edit bool true Inherited from common field options
show_on_hover bool false 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:

  1. 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.
  2. 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:

  1. Per-cell validation. Type check, required check, 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>.
  2. 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).
  3. min_rows / max_rows check.
  4. 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.

[
  {"substance": "flour",  "amount": 200, "unit": "g"},
  {"substance": "sugar",  "amount": 100, "unit": "g"},
  {"substance": "butter", "amount": 50,  "unit": "g"}
]

Compact Mode

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:

[
  {1: "flour",  2: 200, 3: "g"},
  {1: "sugar",  2: 100, 3: "g"}
]

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.

Frontend (React / TypeScript)

  • NewTableFieldEditor component: inline grid, per-cell editor dispatch by column type, add/delete/reorder row controls.
  • Row-level drag-and-drop ordering; commits a singleUpdateField op with the full new array.
  • Per-cell error rendering based on the error keys from core.
  • Row-level and table-level error banners.
  • Read-only rendering for view mode and hover tooltips.
  • Keyboard navigation (Tab / Shift-Tab, Enter in last cell of last row adds row).
  • note_link column picker reuses the existing note_link picker including the filter.

Operations Log Viewer

  • RenderUpdateField ops on table fields with a row-count and row-diff summary instead of a raw CBOR dump.

Documentation

  • Addtype: "table" row to the field type table in the-schema.md.
  • Add the table-level and column-level option tables (copy from §3 and §4).
  • Add a short entry inuse-cases.md under the Recipe example pointing at the new field type.
  • Updatekrillnotes-2-spec.md with the CBOR array-of-maps value form and the column index note.

Tests

  • Round-trip CBOR encode/decode in text-key and compact-key modes.
  • Validation order: per-cell, per-row, min/max rows, per-table.
  • Migration scenarios: add column with default, remove column, rename column viamigrate.
  • LWW behaviour on concurrent table edits (two peers, same note, different row additions).
  • Schema load failures: nested table, duplicate column name, changed column index.
  • Unknown column key preserved on round-trip.
  • 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:

  1. 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.
  2. Clipboard copy/paste. Should the grid support copy/paste from spreadsheet tools (TSV on clipboard)? Useful but non-trivial; defer unless it’s easy.
  3. 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.
  4. 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.

Changelog: v0.1 — initial specification, 2026-04-22.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions