Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9ded5fa
Add App Store Connect API key notarization and the desktop icon gener…
claude Oct 10, 2026
9bd45cf
Ship the ixtable mark as the desktop icon set and re-enable nightly b…
claude Oct 10, 2026
1258cec
Use the white-tile icon; the brand has no dark variant
claude Oct 10, 2026
b4c3f60
Drop the border from the icon tile
claude Oct 10, 2026
ae20c87
Add per-app access, annual billing and org billing customers to Cloud
claude Oct 10, 2026
e0e01ce
Add running sums, conditional formatting and charts to reports
claude Oct 10, 2026
1b17ec2
Add the new report engine and designer modules
claude Oct 10, 2026
8c1aef3
Add rich-text, attachment, and multi-select fields and input masks
claude Oct 10, 2026
6963415
Save and reopen the document so the report test sees its saved query
claude Oct 10, 2026
2e09a95
Add field formats screenshot spec
claude Oct 10, 2026
9bf3fca
Label field input masks plainly in the table designer
claude Oct 11, 2026
18588f0
Add App QA captures for report running sums, rules and charts
claude Oct 11, 2026
9c29495
Scroll to top before the Access tab capture so the sticky navbar does…
claude Oct 11, 2026
7badebd
Merge main into claude/project-thread-81b9kq
claude Oct 12, 2026
d0bd99e
Merge remote-tracking branch 'origin/claude/project-thread-j6wonf' in…
claude Oct 12, 2026
585ed22
Merge remote-tracking branch 'origin/claude/project-thread-oglx44' in…
claude Oct 12, 2026
c81fa59
Merge main into claude/project-thread-au1y46
claude Oct 12, 2026
afe2697
Merge main into claude/project-thread-au1y46
claude Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ every risk-retirement spike. This index maps each spike to its record.
| [Windows support](./windows-support.md) | none. Windows-only failures in Rust tests and the NAPI test bridge | accepted, pending Windows CI (no run has reached a runner yet) |
| [Action queries](./action-queries.md) | none. Saved queries that change rows: DuckDB SQL through a writable attach, per-row triggers | accepted |
| [Access import](./access-import.md) | none. One-way import of Access databases and templates. Format spec in [`docs/access-format.md`](../access-format.md) | accepted |
| [Field formats](./field-formats.md) | none. Rich text, attachment, and multi-select fields and input masks (PRD Phase 6) | accepted |
| [Data export](./data-export.md) | none. CSV, XLSX and JSON export streamed from DuckDB (PRD Phase 6) | accepted |
| [Datasheet tools](./datasheet-tools.md) | none. Filter by selection, find and replace, frozen and hidden columns, totals row, spreadsheet paste (PRD Phase 6) | accepted |

Expand Down
5 changes: 3 additions & 2 deletions docs/decisions/datasheet-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ The Data mode grid moved from `DatabaseWorkbench` into `src/data/sheet/`.
- **Sort.** Clicking a header sorts by it. Shift-click adds the column as the
next sort key (`sorts.ts`). The column menu also sorts.
- **Find and replace** (`FindReplace.tsx`, `find.ts`) matches the text a cell
shows, skipping nulls and blobs. Find next walks the filtered, sorted table
shows (masked or formatted by its field settings), skipping nulls and blobs. Find next walks the filtered, sorted table
one page at a time, wraps once, and moves the grid to the match. Replace all
reads every matching row, asks for confirmation, and sends one
`writeRecordBatch` with `expected` values.
Expand All @@ -39,7 +39,8 @@ The Data mode grid moved from `DatabaseWorkbench` into `src/data/sheet/`.
average, standard deviation and variance need a numeric column.
- **Paste** (`paste.ts`) takes over a paste that spans more than one cell.
It parses tab-separated clipboard text with quoted fields, maps it onto the
visible editable columns from the focused cell, and sends one
visible editable columns (not generated, blob, or formatted fields) from the
focused cell, applies input masks, and sends one
`writeRecordBatch`. Rows that land on existing records update them (an empty
cell becomes null). Rows past the last record, or pasted into the new-record
row, are inserted (an empty cell keeps the default). A cell that does not
Expand Down
118 changes: 118 additions & 0 deletions docs/decisions/field-formats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Field formats: rich text, attachments, multiple choices, and input masks

Status: accepted. Implements the PRD Phase 6 item "attachment, rich-text memo,
multi-select fields, and input masks". Extends the
[type matrix](./recordstore-type-matrix.md) without adding logical types, and
narrows PRD §18's "record-linked attachments are deferred".

## Context

Access users expect four field kinds that ixtable did not have: a memo that
keeps bold, italics, and lists; an attachment field that holds files per
record; a field that holds several choices from a list; and an input mask
such as `(000) 000-0000` that shapes what is typed.

Logical types are the contract between the RecordStore, DuckDB, and the
designer. A new logical type would need a physical type on both stores, a
DuckDB read mapping, a CHECK constraint on SQLite, and a table rebuild to
change. None of the four kinds needs new storage semantics: they are text or
JSON with rules about entry and display. Attachment bytes are the exception,
and they must follow the same write and read paths as records.

## Decision

**Field settings sit on the entity, keyed by column.** `EntitySettings.fields`
(`src-tauri/src/recordstore/model.rs`, `src/fields/types.ts`) holds one
`FieldSettings` per column: a stable `id`, the `column`, an optional `format`
(`richText`, `attachment`, `multiSelect`), an optional `inputMask`, and the
`options` of a multi-select. The table designer edits them in its Field
settings section (`src/fields/FieldSettingsEditor.tsx`) as ordinary definition
edits; they never change the column. `commands::alter_table` renames a field's
column with the column and drops it with the column (`follow_columns`).
`recordstore::validate` flags a duplicate column, an unknown format, and a
multi-select without choices.

| Format | Column logical type | Stored value |
|---|---|---|
| `richText` | `text` | sanitized HTML subset |
| `attachment` | `text` or `json` | JSON array of `{id, name, mime, size, sha256}` |
| `multiSelect` | `text` or `json` | JSON array of chosen strings |
| input mask | `text` | typed characters only, or with literals when the mask's second section is `0` |

**Forms inherit from fields.** Three control kinds, `richText`, `attachment`,
and `multiSelect`, join `ControlKind` in Rust and TypeScript, and a control has
an optional `inputMask`. The generator picks the kind and mask from the field
settings. At run time `withFieldDefaults` (`src/fields/defaults.ts`) upgrades a
plain text control bound to a formatted field to that kind, fills a text
control's mask from the field when it has none, and fills a multi-select's
choices. A mask set on the control wins.

**Rich text is sanitized twice.** `sanitizeRichText` (`src/fields/richtext.ts`)
keeps `p`, `br`, `div`, `b`, `strong`, `i`, `em`, `u`, `s`, `ul`, `ol`, `li`,
`h1` to `h3`, `blockquote`, and `a` with an `http`, `https`, or `mailto` link.
It drops every other attribute, and drops `script`, `style`, and embedded
content with their text. The editor sanitizes what it emits, and the read-only
view sanitizes again before rendering, because a value can also come from an
import, an action, or another PostgreSQL client. Lists, the datasheet,
reports, and the required check use `richTextPlain`.

**Attachment bytes live in the record store.** `record_attachments.rs` writes
each file through the session's `RecordStore` into the hidden
`_ixtable_attachments` table (BLOB on SQLite, BYTEA on PostgreSQL), created on
first use with `execute_internal`. Binds are text on both stores, so content
travels as hex (`unhex` on SQLite, `decode(..., 'hex')` on PostgreSQL). Reads
go through DuckDB like every other read and check the SHA-256 recorded at
upload. The commands are:

- `upload_record_attachment`: needs create or update permission on the table,
refuses a column that is not an attachment field, caps a file at 20 MB, and
cleans the file name with `assets::safe_file_name`.
- `read_record_attachment` and `save_record_attachment`: need read permission
on the table the file was uploaded for.
- `remove_unused_record_attachments`: Studio only. Deletes files no attachment
column refers to, keeping files younger than an hour because an open form may
not have saved them yet.

**Masks follow Access syntax.** `src/fields/mask.ts` parses the three
sections (pattern, store literals, placeholder) and the slot characters
`0 9 # L ? A a & C`, case markers `> <`, `\` escapes, and quoted literals. It
skips `!` and does not support the `Password` mask. Like Access, a mask
governs entry only: forms check it in `validateControl`, and the datasheet
checks it before the logical type (`datasheetValue`). Writes from actions,
imports, and other clients are not checked.

## Consequences

- No DDL, table rebuild, or new DuckDB mapping is needed to adopt a format, and
an older build that does not know `fields` drops them without breaking the
table.
- Attachments travel with embedded SQLite data, so a runtime installation's
files stay with its records through definition updates, and PostgreSQL
users share them. They count toward the `.ixt` archive size for SQLite apps.
- A file uploaded from a form that is then cancelled stays stored until
cleanup runs. Cleanup is a manual Studio action.
- The datasheet shows formatted fields as text and edits them only in forms.
Its edits, inserts, multi-cell paste and find and replace all parse through
`datasheetValue`, so a mask applies to each of them, and find matches the
masked or formatted text a cell shows (`src/data/sheet/rows.ts`, `find.ts`).
- A query-sourced report sees the stored HTML or JSON. Only a table-sourced
report turns formatted fields into text (`fieldTextRows`).
- Expressions see the stored value: HTML for rich text and JSON text for
attachments and multiple choices.

## Evidence

- `src-tauri/src/record_attachments_tests.rs`: hex and checksum, the stored
bytes on SQLite, and store, read, checksum, and unused-file detection through
DuckDB on both stores (`postgres::` variants run in the PostgreSQL job).
- `src-tauri/src/recordstore/fields_tests.rs`: serde shape, column renames and
drops, and validation.
- `tests/unit/field-mask.test.ts`, `tests/unit/field-formats.test.ts`,
`tests/unit/field-inputs.test.tsx`: masks, sanitizing, value parsing,
inheritance, generation, validation, and the inputs.
- `tests/integration/field-formats.test.tsx`: upload and read through the
bridge, setting formats in the table designer, entering each kind in a
generated form, the datasheet mask check, and masked and formatted cells in
the datasheet.
- `tests/unit/datasheet-fields.test.ts`: masks and formats in datasheet
edits, paste, and find.
3 changes: 3 additions & 0 deletions docs/decisions/recordstore-type-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,8 @@ prebuilt libduckdb (see `duckdb-read-path.md`).

## Audit log

- 2026-10-10: field formats (rich text, attachments, multiple choices, input
masks) layer on `text` and `json` columns without new logical types; see
[field formats](./field-formats.md).
- 2026-10-05: Proof now names `conformance_more.rs`, which holds the
optimistic, schema change, and migration scenarios.
95 changes: 95 additions & 0 deletions scripts/screenshot/specs/fields.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { screen, waitFor, within } from "@testing-library/react";
import { invoke } from "@tauri-apps/api/core";
import { expect, it } from "vitest";
import { captureDocument } from "../capture";
import { createTable, LONG, openMode, refreshDatabase, renderNewDocument } from "./fixtures";

type Config = { entities: Array<{ table: string; fields?: Array<{ column: string }> }> };
const fieldCount = async () =>
(await invoke<Config>("read_document_config", { windowLabel: "main" })).entities.find(
(e) => e.table === "contacts",
)?.fields?.length ?? 0;

it("sets field formats in the table designer and enters each kind in the Runtime", async () => {
const user = await renderNewDocument();
await createTable("contacts", [
{ name: "id", declaredType: "INTEGER", primaryKeyPosition: 1 },
{ name: "name", declaredType: "TEXT" },
{ name: "phone", declaredType: "TEXT" },
{ name: "notes", declaredType: "TEXT" },
{ name: "interests", declaredType: "TEXT" },
{ name: "documents", declaredType: "TEXT" },
]);
await refreshDatabase();
await user.click(await screen.findByRole("button", { name: /^contacts\b/ }, LONG));
await user.click(await screen.findByRole("button", { name: "Design table" }, LONG));
const fields = await screen.findByRole("region", { name: "Field settings" }, LONG);
await user.type(
within(fields).getByRole("textbox", { name: /phone input mask/ }),
"(000) 000-0000",
);
await user.selectOptions(
within(fields).getByRole("combobox", { name: "notes format" }),
"richText",
);
await user.selectOptions(
within(fields).getByRole("combobox", { name: "interests format" }),
"multiSelect",
);
await user.type(
await within(fields).findByRole("textbox", { name: "interests choices" }),
"Pricing{Enter}Support{Enter}Training",
);
await user.selectOptions(
within(fields).getByRole("combobox", { name: "documents format" }),
"attachment",
);
await waitFor(async () => expect(await fieldCount()).toBe(4), LONG);
fields.scrollIntoView();
await captureDocument(document, {
name: "fields-01-table-designer",
expand: ".table-designer",
expectations: [
"The Field settings section lists the text columns with a Format picker each.",
"phone shows the input mask (000) 000-0000 and its preview (___) ___-____.",
"notes is Rich text, interests is Multiple choices with three choices, documents is Attachments.",
],
});

await openMode(user, "Design");
await screen.findByRole("region", { name: "Form builder" }, LONG);
await user.selectOptions(
screen.getByRole("combobox", { name: "Table to generate from" }),
"contacts",
);
await user.click(screen.getByRole("button", { name: "Generate form from table" }));
const forms = screen.getByRole("region", { name: "Forms" });
await within(forms).findByRole("button", { name: "Contacts list" }, LONG);
await openMode(user, "Runtime");
const page = await screen.findByRole("region", { name: "Application page" }, LONG);
const nav = screen.getByRole("navigation", { name: "Application navigation" });
await user.click(within(nav).getByRole("button", { name: "Contacts" }));
await user.click(await within(page).findByRole("button", { name: "New contacts" }, LONG));
const create = await screen.findByRole("form", { name: "New Contacts" }, LONG);
await user.type(within(create).getByRole("textbox", { name: "Name" }), "Ada Byrne");
await user.type(within(create).getByRole("textbox", { name: "Phone" }), "5551234567");
await user.type(
within(create).getByRole("textbox", { name: "Notes" }),
"Asked about the annual plan.",
);
await user.click(within(create).getByRole("checkbox", { name: "Support" }));
await user.upload(
within(create).getByLabelText(/Add files/),
new File(["%PDF-1.4 quote"], "quote.pdf", { type: "application/pdf" }),
);
await within(create).findByText("quote.pdf", {}, LONG);
expect(within(create).getByRole("textbox", { name: "Phone" })).toHaveValue("(555) 123-4567");
await captureDocument(document, {
name: "fields-02-runtime-entry",
expectations: [
"The New Contacts form shows Phone formatted as (555) 123-4567.",
"Notes is a rich-text box with a formatting toolbar, and Interests shows three checkboxes with Support ticked.",
"Documents lists quote.pdf with its size and Save and Remove buttons, plus an Add files button.",
],
});
});
4 changes: 4 additions & 0 deletions src-tauri/src/data/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ impl ReadRuntime {
pub(super) fn from(&self, table: &str) -> String {
format!("data.{}.{}", q(self.schema_name()), q(table))
}
/// The read path of any table, including internal `_ixtable_` tables.
pub fn qualified(&self, table: &str) -> String {
self.from(table)
}

pub fn objects(&self) -> Result<Vec<DbObject>, String> {
self.ready()?;
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/design/checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ fn check_control(config: &DocumentConfig, form: &Form, control: &Control, out: &
| ControlKind::Datetime
| ControlKind::Select
| ControlKind::Relationship
| ControlKind::RichText
| ControlKind::Attachment
| ControlKind::MultiSelect
);
if needs_binding
&& control.binding.as_ref().is_none_or(|b| b.column.is_empty())
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/design/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ pub enum ControlKind {
Tabs,
RelatedList,
Image,
/// Formatted text stored as sanitized HTML.
RichText,
/// Files of an attachment field.
Attachment,
/// Several choices stored as a JSON array.
MultiSelect,
}
impl ControlKind {
pub fn is_container(self) -> bool {
Expand Down Expand Up @@ -208,6 +214,9 @@ pub struct Control {
pub asset_id: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub read_only: bool,
/// Access-style input mask for text entry (`src/fields/mask.ts`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_mask: Option<String>,
/// Presentation variant, e.g. "toggle" for booleans.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub mod migrations;
pub mod paths;
pub mod postgres;
pub mod queries;
pub mod record_attachments;
pub mod recordstore;
pub mod recovery;
mod report_pdf;
Expand Down Expand Up @@ -441,6 +442,11 @@ pub fn run() {
recordstore::runtime_login::runtime_datasource_login_status,
recordstore::runtime_login::set_runtime_datasource_login,
recordstore::runtime_login::clear_runtime_datasource_login,
// record attachment commands
record_attachments::upload_record_attachment,
record_attachments::read_record_attachment,
record_attachments::save_record_attachment,
record_attachments::remove_unused_record_attachments,
// roles commands
authz::set_runtime_role_preview,
// design commands
Expand Down
Loading
Loading