Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 32 additions & 5 deletions docs/decisions/action-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,38 @@ foreign keys and `ON DELETE CASCADE` are enforced by SQLite on every
platform. PostgreSQL runs
every kind directly, `postgres_scanner` handles every type.

**Triggers.** When the target has enabled `created` or `updated` triggers, the
run finds the changed rows: inserts compare key sets before and after,
updates compare every column by key, and `replace` reports every row as
created. `run_action_query` returns the created identities, the updated
identities with their old values, and one sync-trigger grant per event.
**Triggers.** When the target has enabled `created`, `updated` or `deleted`
triggers, the run finds the changed rows: inserts compare key sets before and
after, updates compare every column by key, deletes keep every column of the
rows that are gone, and `replace` reports every old row as deleted and every
new row as created. `run_action_query` returns the created identities, the
updated identities with their old values, the deleted rows with their values,
and one sync-trigger grant per event. Rows an SQL cascade removes from other
tables do not fire those tables' triggers.
`src/lib/records.ts` `runActionQuery` calls each record hook once with a
single write whose `meta.bulk` carries those rows, and `src/automation/triggers.ts`
fires the triggers once per row, in order, as if each row had been written
alone. Each use of a grant extends it (`trigger_auth::verify`), so one grant
covers all rows of an event. It is released when they are done. A table with
triggers needs a primary key.

**Before-change triggers.** They run in TypeScript, so a run on a table with
enabled before-change triggers (any kind but delete) takes two calls
(`queries::action_before`). The first runs the statement, collects every row
it would create or update (all new values, and the old values of updated
rows), rolls back, and returns them as `pending` with a fingerprint. The
caller runs the triggers on each row; a rejection ends the run there, with
nothing written. The second call carries `before`: the fingerprint and the
fields set per row, by position. It runs the statement again, refuses with
CONFLICT and rolls back if the rows differ, and applies the fields in the
same transaction: an UPDATE by key on PostgreSQL, an UPDATE of the copy
before it is compared for an embedded-SQLite update, and for rows an
embedded-SQLite insert created, a DELETE and INSERT of the row (the
attachment cannot UPDATE a date column). The fingerprint leaves out new rows'
defaulted and identity columns, whose values can change between the calls,
which is also why fields are matched by position. Trigger fields cannot
change a row's key. A dry run skips the triggers.

**Permissions.** A run needs the role's table operations: `create` for insert,
`update`, `delete`, and both `delete` and `create` for replace
(`trigger_auth::action_query_ops`). The user-mode trigger precheck runs as for
Expand Down Expand Up @@ -137,6 +157,11 @@ becomes a `runQuery` step.
key, inserts with date defaults, dry runs and constraint rollback on a
real SQLite file, foreign keys and cascades with the journal mode kept,
validation, and (ignored, CI runs it) the PostgreSQL path.
- `src-tauri/src/queries/action_before_tests.rs`: deleted rows of deletes
and replaces, pending rows and applied trigger fields for inserts and
embedded updates (a date column included), refused stale fingerprints, key
columns and unknown rows, and (ignored, CI runs it) the PostgreSQL path
with an identity column.
- `src-tauri/src/trigger_auth_tests.rs`: a grant extended by each use.
- `src-tauri/src/access/tests/translate.rs` and `convert.rs`: Access action
statements translated and run in DuckDB. An imported template's append,
Expand All @@ -157,3 +182,5 @@ becomes a `runQuery` step.
- 2026-10-10: PRD §9.4, §10 and §12.1 now allow the DuckDB write path for
user-written action queries only, and require store constraints, including
foreign keys, to hold as they do for RecordStore writes.
- 2026-10-10: deleted triggers fire on delete and replace queries, and
before-change triggers run on each pending row through a second call.
1 change: 1 addition & 0 deletions docs/decisions/archive-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,4 @@ checkpoints are validated archive copies under
and added the matching `archive_io` and `recovery.rs` tests to Evidence.
- 2026-10-05 (after merging #36): re-checked the incremental-save text and evidence #36 added (`archive_io/reuse.rs`, `reuse_tests.rs`, `durability_tests/incremental.rs`, `Snapshot::reuse_from`, `DataStamp`); they match the code. No changes.
- 2026-10-09: config version 4 adds `SavedQuery.action` (action queries); version 3 configs upgrade on load.
- 2026-10-10: config version 5 adds the `deleted` and `beforeChange` trigger events and the `setField` step; version 4 configs upgrade on load.
53 changes: 48 additions & 5 deletions docs/decisions/async-trigger-queue.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# Record triggers and the local async job queue

Status: accepted. Covers PRD §17.3 and the Phase 0 local async trigger queue
spike.
Status: accepted. Covers PRD §17.3 (including the Phase 7 before-change and
record-deleted triggers) and the Phase 0 local async trigger queue spike.

## Context

The MVP supports record-created and record-updated triggers. A trigger runs an
action either inside the initiating workflow or later through a durable local
queue. The queue only runs while the desktop app is open, and it must provide
The MVP supports record-created and record-updated triggers. Phase 7 adds
before-change triggers, which can set fields of a record or reject its save,
and record-deleted triggers. A trigger runs an action either inside the
initiating workflow or later through a durable local queue. The queue only runs while the desktop app is open, and it must provide
retries, status, attempt history, cancellation, and idempotency keys.
Schedules, webhooks, and always-on workers are deferred.

Expand All @@ -33,6 +34,38 @@ forms, grids, dashboards, and actions passes through trigger matching in
- **Async triggers** enqueue a job and return at once.
- Each write carries its trigger depth. A chain deeper than 5 fails, which
stops trigger loops.
- **Deleted triggers** (`event: "deleted"`) run after a delete commits, sync or
async. The hook reads the row before the delete (or takes `meta.old`), and
`record` and `old` are that row. Rust issues a grant for sync app-mode
deleted triggers like it does for created and updated ones.

### Before-change triggers

A trigger with `event: "beforeChange"` runs before a create or update is sent
to Rust, from the `before` record hook. It is always sync (validation refuses
async). `record` is the record about to be saved: the supplied values for a
create, the stored row merged with the changed values for an update. `old` is
the stored row, or null for a create. Triggers run in order, and each sees the
fields earlier ones set.

- Its action may only use `setField` (sets a field of the pending record;
later steps see it in `record`), `condition`, `fail`, `runQuery` on a read
query, and `runAction` with the same limits. This follows Access's Before
Change data macros. Validation flags other steps, and the runner fails them.
`setField` outside a before-change trigger fails.
- A failing action rejects the save with `BeforeChangeRejected`: "Not saved:"
and the fail step's message, or the trigger's name and error. Nothing has
been sent, and the action cannot write, so a rejection never leaves a
partial write. In `writeRecordBatch` it aborts the whole batch.
- The fields it sets replace or join the write's values. Rust applies the
same validation and permissions to them as to the caller's own values.
- `runAs` keeps its meaning. App mode skips the role's checks in TypeScript
(reads still go through Rust's role checks) and needs no grant, since the
action writes nothing. User mode checks the role, and Rust refuses an
insert or update up front when the role could not run a user-mode
before-change trigger (`check_user_triggers` with `BeforeChange`).
- Action queries hand each row they would create or update to the triggers
through the `beforeBulk` hook (see [action queries](./action-queries.md)).

### Execution identity

Expand Down Expand Up @@ -144,6 +177,15 @@ authorized against both the active role and the role it was enqueued under

## Evidence

- `src-tauri/src/automation.rs` tests: before-change triggers limited to
their step kinds and to sync, `setField` refused elsewhere.
- `tests/unit/before-change-triggers.test.ts`: fields set on inserts and
updates, rejections with no write (single and batch), refused steps,
chained triggers, deleted triggers sync and async, and the action query
two-call flow.
- `tests/integration/before-change-triggers.test.tsx`: the same through the
real bridge, including a date set on every row of an embedded-SQLite update
query and a rejection that leaves the table unchanged.
- `src-tauri/src/jobs.rs` tests: idempotent enqueue per store, atomic and
exclusive claims, exponential backoff until failed, completion with log,
cancel and retry, expired leases recovered after restart, `active_lease`
Expand Down Expand Up @@ -176,3 +218,4 @@ authorized against both the active role and the role it was enqueued under
- 2026-10-05: Stated the worker's polling and wake conditions, the role check
for user-mode jobs, and silent `STALE_LEASE` handling; added the
`active_lease` test to Evidence.
- 2026-10-10: Phase 7 before-change and record-deleted triggers.
2 changes: 1 addition & 1 deletion golden/crm/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Pure definition data: the same DocumentConfig YAML the Settings › YAML tab edits.
# Records come from seed.sql, applied as the "Seed sample data" migration.
# Object ids are fixed UUIDv7 literals. A trailing comment names each top-level object.
version: 4
version: 5
name: CRM
activeMode: run
settings:
Expand Down
2 changes: 1 addition & 1 deletion golden/inventory/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# {{asset:<file>}} placeholders are replaced with the ids of the template's assets
# when a document is created from the template.
# Object ids are fixed UUIDv7 literals. A trailing comment names each top-level object.
version: 4
version: 5
name: Inventory
activeMode: run
settings:
Expand Down
2 changes: 1 addition & 1 deletion golden/work-orders/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Pure definition data: the same DocumentConfig YAML the Settings › YAML tab edits.
# v2/app.yaml is the next version of this application (migration fixture).
# Object ids are fixed UUIDv7 literals. A trailing comment names each top-level object.
version: 4
version: 5
name: Work orders
activeMode: run
settings:
Expand Down
2 changes: 1 addition & 1 deletion golden/work-orders/v2/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# work_orders.due_on, and the definitions that use it. Applying this YAML to a
# version 1 document and running pending migrations upgrades it in place.
# Object ids are fixed UUIDv7 literals. A trailing comment names each top-level object.
version: 4
version: 5
name: Work orders
activeMode: run
settings:
Expand Down
169 changes: 169 additions & 0 deletions scripts/screenshot/specs/triggers.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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 { LONG, openMode, startFromTemplate, type User } from "./fixtures";

const runtimePage = () => screen.getByRole("region", { name: "Application page" });

const titles = async () =>
(
await invoke<{ rows: Array<Array<{ value?: unknown }>> }>("execute_read_query", {
windowLabel: "main",
sql: "SELECT title FROM work_orders WHERE number LIKE 'WO-2%' ORDER BY number",
})
).rows.map((r) => r[0]?.value);

async function addStep(user: User, kind: string) {
await user.selectOptions(screen.getByRole("combobox", { name: "New step kind" }), kind);
await user.click(screen.getByRole("button", { name: "Add step" }));
}

async function newWorkOrder(user: User, number: string, title: string) {
const page = await screen.findByRole("region", { name: "Application page" }, LONG);
const nav = await screen.findByRole("navigation", { name: "Application navigation" }, LONG);
await user.click(await within(nav).findByRole("button", { name: "Work orders" }, LONG));
await user.click(await within(page).findByRole("button", { name: "New work order" }, LONG));
const create = await within(page).findByRole("form", { name: "New Work order" }, LONG);
await user.type(within(create).getByRole("textbox", { name: "Number" }), number);
await user.type(within(create).getByRole("textbox", { name: "Title" }), title);
for (const [label, option] of [
["Asset", "Rooftop HVAC unit"],
["Priority", "High"],
]) {
const select = await within(create).findByRole("combobox", { name: label }, LONG);
await within(select).findByRole("option", { name: option }, LONG);
await user.selectOptions(select, option);
}
await user.click(within(create).getByRole("button", { name: "Create" }));
return create;
}

it("sets fields and rejects saves with a before-change trigger, and edits a deleted trigger", async () => {
const user = await startFromTemplate("Work orders");
await openMode(user, "Automation");
await screen.findByRole("heading", { name: "Automation" }, LONG);

// An action that upper-cases the title and refuses short titles.
await user.click(screen.getByRole("tab", { name: "Actions" }));
await user.click(await screen.findByRole("button", { name: "New action" }, LONG));
const name = await screen.findByRole("textbox", { name: "Action name" }, LONG);
await user.clear(name);
await user.type(name, "Check work order");
await addStep(user, "fail");
const fail = await screen.findByRole("group", { name: "Step 1: Fail with message" }, LONG);
await user.type(
within(fail).getByRole("textbox", { name: "Failure message" }),
"'Titles need at least 5 characters'",
);
await user.type(
within(fail).getByRole("textbox", { name: "Only when" }),
"len(record.title) < 5",
);
await addStep(user, "setField");
const setField = await screen.findByRole(
"group",
{ name: "Step 2: Set field (before-change)" },
LONG,
);
await user.type(within(setField).getByRole("textbox", { name: "Field" }), "title");
await user.type(within(setField).getByRole("textbox", { name: "Value" }), "upper(record.title)");
await waitFor(
() =>
expect(within(setField).getByRole("textbox", { name: "Value" })).toHaveValue(
"upper(record.title)",
),
LONG,
);
await captureDocument(document, {
name: "triggers-01-set-field-action",
expectations: [
"The Check work order action has a Fail with message step guarded by len(record.title) < 5.",
"Its second step is 'Set field (before-change)' setting title to upper(record.title).",
],
});

// A before-change trigger on work_orders that runs it.
await user.click(screen.getByRole("tab", { name: "Triggers" }));
await user.click(await screen.findByRole("button", { name: "New trigger" }, LONG));
const triggerName = await screen.findByRole("textbox", { name: "Trigger name" }, LONG);
await user.clear(triggerName);
await user.type(triggerName, "Check work orders before saving");
await user.selectOptions(screen.getByRole("combobox", { name: "Table" }), "work_orders");
await user.selectOptions(screen.getByRole("combobox", { name: "Event" }), "beforeChange");
await user.selectOptions(screen.getByRole("combobox", { name: "Action" }), "Check work order");
expect(screen.queryByLabelText("Run")).toBeNull();
expect(screen.getByText(/Runs before each new or changed record is saved/)).toBeInTheDocument();
expect(screen.queryByText(/Sync triggers run after the record is saved/)).toBeNull();
expect(
within(screen.getByRole("combobox", { name: "Event" }))
.getAllByRole("option")
.map((o) => o.textContent),
).toEqual(["Before a record is saved", "Record created", "Record updated", "Record deleted"]);
await captureDocument(document, {
name: "triggers-02-before-change-editor",
expectations: [
"The new trigger's Event reads 'Before a record is saved' on table work_orders with action Check work order.",
"The sync/async Run select is gone; the only help text says it can set fields or reject the save and cannot write records.",
],
});

// Runtime: a short title is refused with the trigger's message and nothing is saved.
await openMode(user, "Runtime");
const refused = await newWorkOrder(user, "WO-2001", "Fix");
await waitFor(
() =>
expect(
within(refused)
.queryAllByRole("alert")
.map((a) => a.textContent)
.join("\n"),
).toMatch("Not saved: Titles need at least 5 characters"),
LONG,
);
expect(await titles()).toEqual([]);
await captureDocument(document, {
name: "triggers-03-save-rejected",
expectations: [
"The New Work order form stays open with the error 'Not saved: Titles need at least 5 characters'.",
"The entered number WO-2001 and title Fix are still in the form.",
],
});

// A valid title is saved upper-cased by the Set field step.
await newWorkOrder(user, "WO-2002", "replace hvac belt");
const saved = await within(runtimePage()).findByRole("form", { name: "Work order" }, LONG);
await waitFor(
() =>
expect(within(saved).getByRole("textbox", { name: "Title" })).toHaveValue(
"REPLACE HVAC BELT",
),
LONG,
);
expect(await titles()).toEqual(["REPLACE HVAC BELT"]);
await captureDocument(document, {
name: "triggers-04-field-set-on-save",
expectations: [
"The saved work order WO-2002 shows its title as REPLACE HVAC BELT, upper-cased by the trigger.",
],
});

// A record-deleted trigger keeps the Run and Run as options of an after trigger.
await openMode(user, "Automation");
await user.click(await screen.findByRole("tab", { name: "Triggers" }, LONG));
await user.click(await screen.findByRole("button", { name: "New trigger" }, LONG));
const deletedName = await screen.findByRole("textbox", { name: "Trigger name" }, LONG);
await user.clear(deletedName);
await user.type(deletedName, "Log deleted work orders");
await user.selectOptions(screen.getByRole("combobox", { name: "Table" }), "work_orders");
await user.selectOptions(screen.getByRole("combobox", { name: "Event" }), "deleted");
await user.selectOptions(screen.getByRole("combobox", { name: "Action" }), "Log new work order");
expect(screen.getByLabelText("Run")).toHaveValue("sync");
await captureDocument(document, {
name: "triggers-05-deleted-editor",
expectations: [
"The new trigger's Event reads 'Record deleted' on table work_orders with action Log new work order.",
"Run as and the sync/async Run select are shown, and the help text says a deleted record is seen as it was.",
],
});
});
6 changes: 6 additions & 0 deletions src-tauri/src/access/tests/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,12 @@ fn action_queries_convert_and_run(m: &DocumentManager, config: &crate::archive::
kind,
watch: false,
dry_run: false,
deleted: false,
before: Default::default(),
sqlite: true,
logical: vec![],
generated: vec![],
defaulted: vec![],
};
let Plan::Direct(sql) = plan(q("MakeCustomerTotals"), &make, "main", true)
.unwrap()
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pub use crate::archive_io::{
read_archive, read_header, write_archive, ArchiveHeader, FORMAT_VERSION, MIN_FORMAT_VERSION,
};
/// Current `DocumentConfig.version`. Version 2 configs load through serde defaults.
pub const CONFIG_VERSION: u32 = 4;
pub const CONFIG_VERSION: u32 = 5;

#[derive(Debug, thiserror::Error)]
pub enum ArchiveError {
Expand Down
Loading
Loading