diff --git a/docs/decisions/action-queries.md b/docs/decisions/action-queries.md index cce8734..3401730 100644 --- a/docs/decisions/action-queries.md +++ b/docs/decisions/action-queries.md @@ -79,11 +79,14 @@ 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 @@ -91,6 +94,23 @@ 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 @@ -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, @@ -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. diff --git a/docs/decisions/archive-format.md b/docs/decisions/archive-format.md index 2ecc8a6..71e2a4c 100644 --- a/docs/decisions/archive-format.md +++ b/docs/decisions/archive-format.md @@ -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. diff --git a/docs/decisions/async-trigger-queue.md b/docs/decisions/async-trigger-queue.md index 35efe68..d82a934 100644 --- a/docs/decisions/async-trigger-queue.md +++ b/docs/decisions/async-trigger-queue.md @@ -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. @@ -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 @@ -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` @@ -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. diff --git a/golden/crm/app.yaml b/golden/crm/app.yaml index 2ad3b8a..01a089d 100644 --- a/golden/crm/app.yaml +++ b/golden/crm/app.yaml @@ -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: diff --git a/golden/inventory/app.yaml b/golden/inventory/app.yaml index 16de249..ea24bed 100644 --- a/golden/inventory/app.yaml +++ b/golden/inventory/app.yaml @@ -3,7 +3,7 @@ # {{asset:}} 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: diff --git a/golden/work-orders/app.yaml b/golden/work-orders/app.yaml index e2a9867..50f46a6 100644 --- a/golden/work-orders/app.yaml +++ b/golden/work-orders/app.yaml @@ -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: diff --git a/golden/work-orders/v2/app.yaml b/golden/work-orders/v2/app.yaml index cd5e2b4..3a9b0f4 100644 --- a/golden/work-orders/v2/app.yaml +++ b/golden/work-orders/v2/app.yaml @@ -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: diff --git a/scripts/screenshot/specs/triggers.spec.tsx b/scripts/screenshot/specs/triggers.spec.tsx new file mode 100644 index 0000000..2cdf46b --- /dev/null +++ b/scripts/screenshot/specs/triggers.spec.tsx @@ -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> }>("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.", + ], + }); +}); diff --git a/src-tauri/src/access/tests/convert.rs b/src-tauri/src/access/tests/convert.rs index bddd585..1fd56f6 100644 --- a/src-tauri/src/access/tests/convert.rs +++ b/src-tauri/src/access/tests/convert.rs @@ -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() diff --git a/src-tauri/src/archive.rs b/src-tauri/src/archive.rs index 251cae7..9e5625a 100644 --- a/src-tauri/src/archive.rs +++ b/src-tauri/src/archive.rs @@ -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 { diff --git a/src-tauri/src/automation.rs b/src-tauri/src/automation.rs index 12e7961..1a96e47 100644 --- a/src-tauri/src/automation.rs +++ b/src-tauri/src/automation.rs @@ -45,12 +45,16 @@ pub struct ActionDef { pub on_error: OnError, } +/// `beforeChange` runs before a create or update is written and may set its +/// fields or reject it; the others run after the write commits. #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "camelCase")] pub enum TriggerEvent { #[default] Created, Updated, + Deleted, + BeforeChange, } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -138,8 +142,14 @@ const STEP_KINDS: &[&str] = &[ "condition", "runAction", "fail", + "setField", ]; +/// The step kinds a before-change trigger's action may use: it computes field +/// values or rejects the save, and never writes records or touches the UI. +pub const BEFORE_CHANGE_STEPS: &[&str] = + &["setField", "condition", "fail", "runQuery", "runAction"]; + fn text<'a>(fields: &'a Map, key: &str) -> &'a str { fields.get(key).and_then(Value::as_str).unwrap_or("") } @@ -311,6 +321,10 @@ fn check_step(config: &DocumentConfig, action: &ActionDef, step: &Step, issues: "confirm" => need(issues, "message", "message expression"), "message" => need(issues, "text", "text expression"), "fail" => need(issues, "message", "message expression"), + "setField" => { + need(issues, "field", "field"); + need(issues, "value", "value expression"); + } "runAction" => { let set = config.actions.iter().map(|a| a.id.as_str()).collect(); missing(issues, "action", text(f, "actionId"), &set); @@ -387,6 +401,27 @@ pub fn validate(config: &DocumentConfig) -> Vec { if t.max_attempts == 0 { err("max attempts must be at least 1".into()); } + let steps = crate::trigger_auth::action_steps(config, &t.action_id); + if t.event == TriggerEvent::BeforeChange { + if t.mode == TriggerMode::Async { + err("a before-change trigger runs before the save, so it cannot be async".into()); + } + for step in &steps { + let action_query = step.kind == "runQuery" + && config.saved_queries.iter().any(|q| { + Some(q.id.as_str()) == step.fields.get("queryId").and_then(Value::as_str) + && q.action.is_some() + }); + if !BEFORE_CHANGE_STEPS.contains(&step.kind.as_str()) || action_query { + err(format!( + "a before-change trigger can only set fields, check conditions, fail, read queries and run actions, not {} steps", + if action_query { "action query" } else { step.kind.as_str() } + )); + } + } + } else if steps.iter().any(|s| s.kind == "setField") { + err("setField steps only run in before-change triggers".into()); + } if t.condition.as_deref().is_some_and(|c| c.trim().is_empty()) { err("condition expression is empty".into()); } @@ -539,6 +574,56 @@ mod tests { } } + #[test] + fn before_change_triggers_only_set_fields_check_and_read() { + let mut c = config( + json!([ + {"id": "stamp", "name": "Stamp", "steps": [ + {"id": "1", "kind": "setField", "field": "status", "value": "upper(record.status)"}, + {"id": "2", "kind": "fail", "message": "'no'", "when": "record.total < 0"}, + {"id": "3", "kind": "runAction", "actionId": "log"} + ]}, + {"id": "log", "name": "Log", "steps": [ + {"id": "4", "kind": "createRecord", "table": "audit", "values": {"m": "'x'"}}, + {"id": "5", "kind": "setField", "field": " ", "value": ""} + ]} + ]), + json!([ + {"id": "b", "name": "B", "table": "orders", "event": "beforeChange", "actionId": "stamp", "mode": "async"}, + {"id": "d", "name": "D", "table": "orders", "event": "deleted", "actionId": "log"} + ]), + ); + let all = messages(&validate(&c)); + for expected in [ + "trigger \"B\": a before-change trigger runs before the save, so it cannot be async", + "trigger \"B\": a before-change trigger can only set fields, check conditions, fail, read queries and run actions, not createRecord steps", + "trigger \"D\": setField steps only run in before-change triggers", + "setField step: field is empty", + "setField step: value expression is empty", + ] { + assert!(all.iter().any(|m| m == expected || m.ends_with(expected)), "missing {expected:?} in {all:#?}"); + } + c.actions[1] + .steps + .retain(|s| s.kind != "createRecord" && s.kind != "setField"); + c.actions[1].steps.push( + serde_json::from_value( + json!({"id": "6", "kind": "condition", "when": "true", "then": [], "else": []}), + ) + .unwrap(), + ); + c.triggers.retain(|t| t.id == "b"); + c.triggers[0].mode = TriggerMode::Sync; + assert!(validate(&c).is_empty(), "{:#?}", validate(&c)); + let t: Trigger = + serde_json::from_value(json!({"id": "t", "name": "T", "event": "beforeChange"})) + .unwrap(); + assert_eq!( + serde_json::to_value(t.event).unwrap(), + json!("beforeChange") + ); + } + #[test] fn detects_run_action_cycles_through_conditions() { let c = config( diff --git a/src-tauri/src/queries/action.rs b/src-tauri/src/queries/action.rs index 40524aa..0ce3587 100644 --- a/src-tauri/src/queries/action.rs +++ b/src-tauri/src/queries/action.rs @@ -3,17 +3,19 @@ //! the datasource attached read-write (`data::write`), in one transaction. //! //! A run checks the role's table permission and the user-mode trigger -//! precheck, like a record write. When the target table has enabled `created` -//! or `updated` triggers, the run copies the table before the statement and -//! compares it after, by primary key, so the caller can fire those triggers -//! once per changed row. The run returns the created identities, the updated -//! identities with their old values, and one sync-trigger grant per event. +//! precheck, like a record write. When the target table has enabled +//! `created`, `updated` or `deleted` triggers, the run copies the table before +//! the statement and compares it after, by primary key, so the caller can fire +//! those triggers once per changed row. The run returns the created +//! identities, the updated identities with their old values, the deleted rows +//! with their values, and one sync-trigger grant per event. Before-change +//! triggers take a second call (`action_before`). use super::{resolve_params, rewrite_placeholders}; use crate::archive::{ActionKind, ActionSpec, DocumentConfig, SavedQuery}; use crate::automation::TriggerEvent; use crate::data::{self, sqltext, DataValue, NamedValue}; use crate::manager::AppError; -use serde::Serialize; +use serde::{Deserialize, Serialize}; /// Words an action statement may not contain: DDL, catalog, settings and file access. const FORBIDDEN: [&str; 37] = [ @@ -235,13 +237,60 @@ pub fn target<'a>(sql: &'a str, spec: &ActionSpec, schema: &str) -> Result>, pub updated: Vec, + pub deleted: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DeletedRow { + pub identity: Vec, + /// Every column's value before the statement. + pub values: Vec, +} + +/// A row before-change triggers decide on: its identity after the statement, +/// every column's new value, and the old values of an updated row. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PendingRow { + pub identity: Vec, + pub values: Vec, + pub old: Option>, +} + +/// The rows a run would create or update, for before-change triggers. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Pending { + /// Hash of `rows` (without the defaulted columns of new rows); the second call must reproduce it. + pub fingerprint: String, + pub rows: Vec, +} + +/// What the caller's before-change triggers decided, for the second call. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BeforeChange { + pub fingerprint: String, + /// Fields the triggers set, per row. + #[serde(default)] + pub overrides: Vec, +} + +/// Fields set on one pending row, found by its position in `Pending::rows` +/// (a new row's key can differ between the calls, e.g. a PostgreSQL sequence). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Override { + pub row: usize, + pub values: Vec, } #[derive(Debug, Clone, Serialize, PartialEq)] @@ -264,17 +313,22 @@ pub struct ActionRun { pub table: String, #[serde(flatten)] pub rows: ChangedRows, - /// Sync app-mode trigger grants for the `created` and `updated` rows. + /// Sync app-mode trigger grants for the `created`, `updated` and `deleted` rows. pub created_grant: Option, pub updated_grant: Option, + pub deleted_grant: Option, + /// Rows awaiting before-change triggers; nothing was written (see `action_before`). + #[serde(skip_serializing_if = "Option::is_none")] + pub pending: Option, } /// The trigger events a kind of action query can raise. fn events(kind: ActionKind) -> &'static [TriggerEvent] { match kind { - ActionKind::Insert | ActionKind::Replace => &[TriggerEvent::Created], + ActionKind::Insert => &[TriggerEvent::Created], + ActionKind::Replace => &[TriggerEvent::Deleted, TriggerEvent::Created], ActionKind::Update => &[TriggerEvent::Updated], - ActionKind::Delete => &[], + ActionKind::Delete => &[TriggerEvent::Deleted], } } @@ -365,12 +419,15 @@ pub fn plan( Ok((plan, rewritten.names)) } -/// Runs an action query. `dry_run` rolls the transaction back and reports the counts. +/// Runs an action query. `dry_run` rolls the transaction back and reports the +/// counts. On a table with before-change triggers, a run without `before` +/// returns the pending rows instead of writing (see `ActionRun::pending`). pub fn run( window: &str, id: &str, supplied: &[NamedValue], dry_run: bool, + before: Option, ) -> Result { let manager = crate::manager()?; let config = manager.config(window)?; @@ -389,6 +446,11 @@ pub fn run( for &event in events(spec.kind) { crate::trigger_auth::precheck(window, &spec.table, event)?; } + let before_change = spec.kind != ActionKind::Delete + && has_triggers(&config, &spec.table, TriggerEvent::BeforeChange); + if before_change { + crate::trigger_auth::precheck(window, &spec.table, TriggerEvent::BeforeChange)?; + } let custom = crate::recordstore::entity_policy(&config, &spec.table) .is_some_and(|e| e.concurrency == "customAction"); if custom && spec.kind != ActionKind::Insert { @@ -403,9 +465,17 @@ pub fn run( let schema = schema_of(&config); let (plan, names) = plan(query, &spec, &schema, sqlite)?; let values = resolve_params(&names, &query.parameters, supplied, true)?; - let watch = events(spec.kind) - .iter() - .any(|&e| has_triggers(&config, &spec.table, e)); + let watch = [TriggerEvent::Created, TriggerEvent::Updated] + .into_iter() + .any(|e| events(spec.kind).contains(&e) && has_triggers(&config, &spec.table, e)); + let deleted = events(spec.kind).contains(&TriggerEvent::Deleted) + && has_triggers(&config, &spec.table, TriggerEvent::Deleted); + let (before, dry_run) = match (before_change, dry_run, before) { + (false, _, _) | (true, true, _) => (super::action_exec::Before::Off, dry_run), + (true, false, None) => (super::action_exec::Before::Collect, true), + (true, false, Some(decided)) => (super::action_exec::Before::Apply(decided), false), + }; + let collecting = matches!(before, super::action_exec::Before::Collect); let (workspace, extension, def) = manager.with_session(window, |s| { let def = s .reader @@ -423,7 +493,20 @@ pub fn run( .iter() .map(|c| (c.name.clone(), c.logical_type.clone())) .collect(); - if watch && keys.is_empty() && !matches!(plan, Plan::Copy(_)) { + let generated = def + .columns + .iter() + .filter(|c| c.generated_expression.is_some()) + .map(|c| c.name.clone()) + .collect(); + let defaulted = def + .columns + .iter() + .filter(|c| c.identity || c.default_expression.is_some()) + .map(|c| c.name.clone()) + .collect(); + let needs_rows = watch || deleted || !matches!(before, super::action_exec::Before::Off); + if needs_rows && keys.is_empty() && !matches!(plan, Plan::Copy(_)) { return Err(invalid(format!( "The table \"{}\" has triggers but no primary key, so an action query cannot tell which rows changed", spec.table @@ -436,7 +519,13 @@ pub fn run( rowid: sqlite && !def.without_rowid, kind: spec.kind, watch, + deleted, + before, dry_run, + sqlite, + logical: logical.clone(), + generated, + defaulted, }; let outcome = crate::recordstore::blocking(|| { let _gate = if sqlite { @@ -479,7 +568,13 @@ pub fn run( rows: outcome.rows, created_grant: None, updated_grant: None, + deleted_grant: None, + pending: outcome.pending, }; + if collecting { + // Nothing was written: the caller runs the before-change triggers first. + run.rows = ChangedRows::default(); + } if !dry_run { crate::recordstore::commands::after_write(window)?; let now = std::time::Instant::now(); @@ -501,6 +596,15 @@ pub fn run( now, ); } + if !run.rows.deleted.is_empty() { + run.deleted_grant = crate::trigger_auth::issue( + window, + &config, + &spec.table, + TriggerEvent::Deleted, + now, + ); + } } Ok(run) } @@ -513,8 +617,18 @@ pub async fn run_action_query( id: String, params: Vec, dry_run: Option, + before: Option, ) -> Result { - super::blocking(move || run(&window_label, &id, ¶ms, dry_run.unwrap_or(false))).await + super::blocking(move || { + run( + &window_label, + &id, + ¶ms, + dry_run.unwrap_or(false), + before, + ) + }) + .await } /// Checks an action query's SQL before it is saved (the guard and placeholders). diff --git a/src-tauri/src/queries/action_before.rs b/src-tauri/src/queries/action_before.rs new file mode 100644 index 0000000..06c1b8b --- /dev/null +++ b/src-tauri/src/queries/action_before.rs @@ -0,0 +1,354 @@ +//! Before-change triggers on action queries (`docs/decisions/action-queries.md`). +//! +//! The triggers run in TypeScript, so a run on a table with before-change +//! triggers takes two calls. The first computes the rows the statement would +//! create or update, rolls back, and returns them with a fingerprint. The +//! caller runs the triggers on each row: any rejection ends there, before +//! anything is written. The second call runs the statement again, checks +//! that it changes the same rows to the same values (else CONFLICT, rolled +//! back), and applies the fields the triggers set in the same transaction. +//! +//! Overrides are applied by key: PostgreSQL rows and the embedded-SQLite +//! update copy with an UPDATE, rows an embedded-SQLite insert created by +//! deleting and inserting them again (the attachment cannot UPDATE a date +//! column, see the record). +use super::action::{BeforeChange, Pending, PendingRow, WORK}; +use super::action_exec::{ + any_differs, column_types, list, named, run_statement, same_keys, select_rows, Job, BEFORE, + ROWID, +}; +use crate::archive::ActionKind; +use crate::data::{self, DataValue, NamedValue}; +use crate::manager::AppError; +use duckdb::types::Value as DuckValue; +use sha2::{Digest, Sha256}; + +fn invalid(message: String) -> AppError { + AppError::new("VALIDATION_ERROR", message) +} + +/// Pending rows of a direct run, in key order: every row an insert added (by +/// the key snapshot), every row a replace wrote, or every row an update changed +/// with its old values (by the full snapshot). +pub fn pending_direct(connection: &duckdb::Connection, job: &Job) -> Result { + let table = &job.table; + let order = list(&job.keys, "t"); + let rows = match job.kind { + ActionKind::Update => { + let columns = column_types(connection, BEFORE)?.0; + let sql = format!( + "SELECT {}, {} FROM {table} t JOIN {BEFORE} b ON {} WHERE {} ORDER BY {order}", + list(&columns, "t"), + list(&columns, "b"), + same_keys(&job.keys), + any_differs(&columns, "t", "b") + ); + select_rows(connection, &sql)? + .1 + .into_iter() + .map(|row| { + let (new, old) = row.split_at(columns.len()); + pending_row(job, &columns, new, Some(old)) + }) + .collect() + } + _ => { + let columns = column_types(connection, table)?.0; + let from = match job.kind { + ActionKind::Insert => { + format!("{table} t ANTI JOIN {BEFORE} b ON {}", same_keys(&job.keys)) + } + _ => format!("{table} t"), + }; + let sql = format!( + "SELECT {} FROM {from} ORDER BY {order}", + list(&columns, "t") + ); + select_rows(connection, &sql)? + .1 + .into_iter() + .map(|row| pending_row(job, &columns, &row, None)) + .collect() + } + }; + Ok(seal(job, rows)) +} + +/// Pending rows of an embedded-SQLite UPDATE, from the copy it ran on. A row +/// is found by its new key, or its rowid when the table has no key. +pub fn pending_copy( + connection: &duckdb::Connection, + job: &Job, + columns: &[String], +) -> Result { + let (key, join) = if job.keys.is_empty() { + (format!("t.{ROWID}"), format!("t.{ROWID} = b.rowid")) + } else { + (list(&job.keys, "t"), same_keys(&job.keys)) + }; + let sql = format!( + "SELECT {key}, {}, {} FROM {WORK} t JOIN {} b ON {join} WHERE {} ORDER BY {key}", + list(columns, "t"), + list(columns, "b"), + job.table, + any_differs(columns, "t", "b") + ); + let width = job.keys.len().max(1); + let rows = select_rows(connection, &sql)? + .1 + .into_iter() + .map(|row| { + let (identity, rest) = row.split_at(width); + let (new, old) = rest.split_at(columns.len()); + PendingRow { + identity: identity.to_vec(), + values: named(columns, new), + old: Some(named(columns, old)), + } + }) + .collect(); + Ok(seal(job, rows)) +} + +fn pending_row( + job: &Job, + columns: &[String], + new: &[DataValue], + old: Option<&[DataValue]>, +) -> PendingRow { + let identity = job + .keys + .iter() + .filter_map(|k| columns.iter().position(|c| c == k).map(|i| new[i].clone())) + .collect(); + PendingRow { + identity, + values: named(columns, new), + old: old.map(|o| named(columns, o)), + } +} + +/// The rows with the fingerprint the second call must reproduce. Defaulted +/// columns of new rows (sequences, timestamps) and their identities are left +/// out: they can differ between the calls without the triggers' input changing. +fn seal(job: &Job, rows: Vec) -> Pending { + let stable: Vec<(&[NamedValue], Vec<&NamedValue>)> = rows + .iter() + .map(|r| match &r.old { + Some(old) => (old.as_slice(), r.values.iter().collect()), + None => ( + &[][..], + r.values + .iter() + .filter(|v| !job.defaulted.contains(&v.column)) + .collect(), + ), + }) + .collect(); + let json = serde_json::to_string(&stable).unwrap_or_default(); + Pending { + fingerprint: format!("{:x}", Sha256::digest(json.as_bytes())), + rows, + } +} + +/// Refuses the run when the statement no longer changes the rows the triggers saw. +pub fn verify(before: &BeforeChange, pending: &Pending) -> Result<(), AppError> { + if before.fingerprint == pending.fingerprint { + return Ok(()); + } + Err(AppError::new( + "CONFLICT", + "The rows this action query changes changed while its before-change triggers ran; run it again", + )) +} + +/// One override, checked: the pending row it sets and its normalized values. +struct Change<'a> { + row: &'a PendingRow, + values: Vec, +} + +fn changes<'a>( + job: &Job, + before: &BeforeChange, + pending: &'a Pending, +) -> Result>, AppError> { + let mut out = vec![]; + for o in before.overrides.iter().filter(|o| !o.values.is_empty()) { + let row = pending.rows.get(o.row).ok_or_else(|| { + invalid( + "A before-change trigger set fields of a row this action query does not change" + .into(), + ) + })?; + let mut values = vec![]; + for v in &o.values { + if job.keys.contains(&v.column) { + return Err(invalid(format!( + "A before-change trigger cannot change the key column {} of a row an action query writes", + v.column + ))); + } + if !row.values.iter().any(|c| c.column == v.column) { + return Err(invalid(format!("{} has no column {}", job.name, v.column))); + } + let value = match job.logical.iter().find(|(n, _)| *n == v.column) { + Some((_, l)) => l.normalize(&v.column, &v.value).map_err(invalid)?, + None => v.value.clone(), + }; + values.push(NamedValue { + column: v.column.clone(), + value, + }); + } + // Several overrides of one row merge, later fields winning. + match out + .iter_mut() + .find(|c: &&mut Change| c.row.identity == row.identity) + { + Some(existing) => { + existing + .values + .retain(|v| !values.iter().any(|n| n.column == v.column)); + existing.values.extend(values); + } + None => out.push(Change { row, values }), + } + } + Ok(out) +} + +/// The key columns of a pending row with their values. +fn key_values(job: &Job, row: &PendingRow) -> Vec { + job.keys + .iter() + .zip(&row.identity) + .map(|(k, v)| NamedValue { + column: k.clone(), + value: v.clone(), + }) + .collect() +} + +fn bind(value: &DataValue) -> Result { + data::read::duck_bind(value).map_err(invalid) +} + +/// `"c" = CAST(? AS type)` for each value, and the bound values. +fn casts( + values: &[NamedValue], + types: &(Vec, Vec), + separator: &str, +) -> Result<(String, Vec), AppError> { + let type_of = |c: &str| { + types + .0 + .iter() + .position(|n| n == c) + .map(|i| types.1[i].clone()) + .unwrap_or_else(|| "VARCHAR".into()) + }; + let sql = values + .iter() + .map(|v| format!("{} = CAST(? AS {})", data::q(&v.column), type_of(&v.column))) + .collect::>() + .join(separator); + let bound = values + .iter() + .map(|v| bind(&v.value)) + .collect::>()?; + Ok((sql, bound)) +} + +/// Applies the triggers' fields to the rows a direct run changed, in its transaction. +pub fn apply_direct( + connection: &duckdb::Connection, + job: &Job, + before: &BeforeChange, + pending: &Pending, +) -> Result<(), AppError> { + let types = column_types(connection, &job.table)?; + for change in changes(job, before, pending)? { + let (find, found) = casts(&key_values(job, change.row), &types, " AND ")?; + if job.sqlite { + // The attachment cannot UPDATE a date column: write the row again instead. + run_statement( + connection, + &format!("DELETE FROM {} WHERE {find}", job.table), + &found, + )?; + let row: Vec = change + .row + .values + .iter() + .filter(|v| !job.generated.contains(&v.column)) + .map(|v| { + change + .values + .iter() + .find(|o| o.column == v.column) + .unwrap_or(v) + .clone() + }) + .collect(); + let (_, bound) = casts(&row, &types, ", ")?; + let columns: Vec = row.iter().map(|v| data::q(&v.column)).collect(); + let slots: Vec = row + .iter() + .map(|v| { + let i = types.0.iter().position(|n| *n == v.column); + let ty = i.map(|i| types.1[i].as_str()).unwrap_or("VARCHAR"); + format!("CAST(? AS {ty})") + }) + .collect(); + run_statement( + connection, + &format!( + "INSERT INTO {} ({}) VALUES ({})", + job.table, + columns.join(", "), + slots.join(", ") + ), + &bound, + )?; + } else { + let (set, mut bound) = casts(&change.values, &types, ", ")?; + bound.extend(found); + run_statement( + connection, + &format!("UPDATE {} SET {set} WHERE {find}", job.table), + &bound, + )?; + } + } + Ok(()) +} + +/// Applies the triggers' fields to the copy an embedded-SQLite UPDATE ran on, +/// before it is compared with the live table. +pub fn apply_copy( + connection: &duckdb::Connection, + job: &Job, + before: &BeforeChange, + pending: &Pending, +) -> Result<(), AppError> { + let types = column_types(connection, WORK)?; + for change in changes(job, before, pending)? { + let (set, mut bound) = casts(&change.values, &types, ", ")?; + let find = if job.keys.is_empty() { + bound.push(bind(&change.row.identity[0])?); + format!("{ROWID} = ?") + } else { + let (find, found) = casts(&key_values(job, change.row), &types, " AND ")?; + bound.extend(found); + find + }; + run_statement( + connection, + &format!("UPDATE {WORK} SET {set} WHERE {find}"), + &bound, + )?; + } + Ok(()) +} diff --git a/src-tauri/src/queries/action_before_tests.rs b/src-tauri/src/queries/action_before_tests.rs new file mode 100644 index 0000000..61f9b13 --- /dev/null +++ b/src-tauri/src/queries/action_before_tests.rs @@ -0,0 +1,343 @@ +//! Deleted rows and before-change triggers on action queries, on a real SQLite file. +use super::action::{BeforeChange, Override, Pending}; +use super::action_exec::{compute_update, direct, Before, Job}; +use super::action_tests::{apply, copy_sql, job, rows, workspace, writer}; +use crate::archive::ActionKind; +use crate::data::logical::LogicalType; +use crate::data::{DataValue, NamedValue}; + +fn shipments(dir: &std::path::Path) { + rusqlite::Connection::open(dir.join("data.db")) + .unwrap() + .execute_batch( + "CREATE TABLE shipments(id INTEGER PRIMARY KEY, order_id INTEGER, shipped DATE, note TEXT, + label TEXT GENERATED ALWAYS AS ('#' || id) VIRTUAL); + INSERT INTO shipments (id, order_id, note) VALUES (1, 2, 'old');", + ) + .unwrap(); +} + +fn shipments_job(kind: ActionKind, before: Before) -> Job { + let declared = [ + ("id", "INTEGER"), + ("order_id", "INTEGER"), + ("shipped", "DATE"), + ("note", "TEXT"), + ]; + Job { + table: "data.\"main\".\"shipments\"".into(), + name: "shipments".into(), + before, + logical: declared + .iter() + .map(|(n, t)| (n.to_string(), LogicalType::from_sqlite_declared(t))) + .collect(), + generated: vec!["label".into()], + ..job(kind, false, false) + } +} + +fn set(row: usize, values: &[(&str, DataValue)]) -> Override { + Override { + row, + values: values + .iter() + .map(|(c, v)| NamedValue { + column: c.to_string(), + value: v.clone(), + }) + .collect(), + } +} + +fn shipped(dir: &std::path::Path) -> Vec<(i64, Option, Option)> { + let c = rusqlite::Connection::open(dir.join("data.db")).unwrap(); + let mut stmt = c + .prepare("SELECT id, shipped, note FROM shipments ORDER BY id") + .unwrap(); + stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) + .unwrap() + .map(Result::unwrap) + .collect() +} + +const INSERT: &str = "INSERT INTO data.main.shipments (id, order_id) SELECT id + 100, id FROM data.main.orders WHERE paid = 0"; + +fn collect(dir: &std::path::Path) -> Pending { + let c = writer(dir); + let mut collecting = shipments_job(ActionKind::Insert, Before::Collect); + collecting.dry_run = true; + direct(&c, &[INSERT.into()], &[], &collecting) + .unwrap() + .pending + .unwrap() +} + +#[test] +fn deletes_and_replaces_report_the_rows_they_removed() { + let dir = workspace(); + let c = writer(&dir); + let deleting = Job { + deleted: true, + ..job(ActionKind::Delete, false, false) + }; + let out = direct( + &c, + &["DELETE FROM data.main.orders WHERE paid = 0".into()], + &[], + &deleting, + ) + .unwrap(); + let ids: Vec<_> = out + .rows + .deleted + .iter() + .map(|r| r.identity.clone()) + .collect(); + assert_eq!(ids, [[DataValue::Integer(1)], [DataValue::Integer(3)]]); + let total = out.rows.deleted[1] + .values + .iter() + .find(|v| v.column == "total") + .unwrap(); + assert_eq!(total.value, DataValue::Real(30.0)); + let replace = vec![ + "DELETE FROM data.main.orders".to_string(), + "INSERT INTO data.main.orders BY NAME (SELECT 7 AS id, 1 AS customer)".to_string(), + ]; + let replacing = Job { + kind: ActionKind::Replace, + ..deleting + }; + let out = direct(&c, &replace, &[], &replacing).unwrap(); + assert_eq!(out.rows.deleted.len(), 1); + assert_eq!(out.rows.deleted[0].identity, [DataValue::Integer(2)]); + drop(c); + assert_eq!(rows(&dir, "SELECT id FROM orders"), [[7]]); +} + +#[test] +fn an_insert_collects_pending_rows_then_applies_the_fields_triggers_set() { + let dir = workspace(); + shipments(&dir); + let pending = collect(&dir); + // The first call rolls back and returns every new row in key order. + assert_eq!(shipped(&dir).len(), 1); + let ids: Vec<_> = pending.rows.iter().map(|r| r.identity.clone()).collect(); + assert_eq!(ids, [[DataValue::Integer(101)], [DataValue::Integer(103)]]); + assert!(pending.rows.iter().all(|r| r.old.is_none())); + assert!(pending.rows[0] + .values + .iter() + .any(|v| v.column == "order_id" && v.value == DataValue::Integer(1))); + let decided = BeforeChange { + fingerprint: pending.fingerprint.clone(), + overrides: vec![ + set(0, &[("shipped", DataValue::Text("2024-05-01".into()))]), + set(0, &[("note", DataValue::Text("rush".into()))]), + ], + }; + let c = writer(&dir); + let out = direct( + &c, + &[INSERT.into()], + &[], + &shipments_job(ActionKind::Insert, Before::Apply(decided)), + ) + .unwrap(); + assert_eq!(out.changed, 2); + drop(c); + assert_eq!( + shipped(&dir), + [ + (1, None, Some("old".into())), + (101, Some("2024-05-01".into()), Some("rush".into())), + (103, None, None), + ] + ); +} + +#[test] +fn a_changed_result_keys_and_unknown_rows_are_refused_with_nothing_written() { + let dir = workspace(); + shipments(&dir); + let pending = collect(&dir); + let run = |overrides: Vec, fingerprint: &str| { + let decided = BeforeChange { + fingerprint: fingerprint.into(), + overrides, + }; + let c = writer(&dir); + direct( + &c, + &[INSERT.into()], + &[], + &shipments_job(ActionKind::Insert, Before::Apply(decided)), + ) + .map(|_| ()) + .unwrap_err() + }; + assert_eq!(run(vec![], "stale").code, "CONFLICT"); + let key = run( + vec![set(0, &[("id", DataValue::Integer(5))])], + &pending.fingerprint, + ); + assert!(key.message.contains("key column id"), "{}", key.message); + let other = run( + vec![set(5, &[("note", DataValue::Text("x".into()))])], + &pending.fingerprint, + ); + assert!( + other.message.contains("does not change"), + "{}", + other.message + ); + let bad = run( + vec![set(0, &[("shipped", DataValue::Text("soon".into()))])], + &pending.fingerprint, + ); + assert_eq!(bad.code, "VALIDATION_ERROR"); + assert_eq!(shipped(&dir).len(), 1); +} + +#[test] +fn an_embedded_update_applies_trigger_fields_to_its_copy() { + let dir = workspace(); + shipments(&dir); + rusqlite::Connection::open(dir.join("data.db")) + .unwrap() + .execute_batch("INSERT INTO shipments (id, order_id, note) VALUES (2, 3, 'old')") + .unwrap(); + let sql = copy_sql("UPDATE shipments SET note = 'packed'", "shipments"); + let c = writer(&dir); + let mut collecting = shipments_job(ActionKind::Update, Before::Collect); + collecting.watch = true; + let computed = compute_update(&c, &sql, &[], &collecting).unwrap(); + let pending = computed.outcome.pending.unwrap(); + assert_eq!(pending.rows.len(), 2); + let old = pending.rows[0].old.as_ref().unwrap(); + assert!(old + .iter() + .any(|v| v.column == "note" && v.value == DataValue::Text("old".into()))); + let decided = BeforeChange { + fingerprint: pending.fingerprint, + overrides: vec![set(1, &[("shipped", DataValue::Text("2024-06-02".into()))])], + }; + let mut applying = shipments_job(ActionKind::Update, Before::Apply(decided)); + applying.watch = true; + let computed = compute_update(&c, &sql, &[], &applying).unwrap(); + drop(c); + assert_eq!(computed.outcome.rows.updated.len(), 2); + apply(&dir.join("data.db"), &applying, &computed.changes).unwrap(); + assert_eq!( + shipped(&dir), + [ + (1, None, Some("packed".into())), + (2, Some("2024-06-02".into()), Some("packed".into())), + ] + ); +} + +#[test] +#[ignore = "needs IXTABLE_TEST_POSTGRES_URL; CI runs it with --include-ignored"] +fn postgres_trigger_fields_and_deleted_rows_go_through_the_attachment() { + let Some(url) = crate::test_env::postgres_url() else { + return; + }; + let schema = format!("ixt_{}", uuid::Uuid::new_v4().simple()); + let mut admin = ::postgres::Client::connect(&url, ::postgres::NoTls).unwrap(); + admin + .batch_execute(&format!( + "CREATE SCHEMA {schema}; + CREATE TABLE {schema}.tasks(id BIGINT PRIMARY KEY, done BOOLEAN NOT NULL DEFAULT false, due DATE, + seq BIGINT GENERATED ALWAYS AS IDENTITY); + INSERT INTO {schema}.tasks (id, done) VALUES (1, false), (2, true);" + )) + .unwrap(); + let dir = std::env::temp_dir().join(format!("ixtable-action-pg-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let extension = crate::data::extensions::sqlite_extension_path().unwrap(); + let target = crate::data::read::ReadTarget::Postgres { + conninfo: url.clone(), + schema: schema.clone(), + }; + let c = crate::data::write::open_writer(&dir, &extension, &target, false).unwrap(); + let tasks = |kind, before| Job { + table: format!("data.\"{schema}\".\"tasks\""), + name: "tasks".into(), + rowid: false, + sqlite: false, + before, + logical: vec![("due".into(), LogicalType::Date)], + generated: vec![], + defaulted: vec!["done".into(), "seq".into()], + ..job(kind, false, false) + }; + let insert = vec!["INSERT INTO tasks (id) VALUES (3), (4)".to_string()]; + let mut collecting = tasks(ActionKind::Insert, Before::Collect); + collecting.dry_run = true; + let pending = direct(&c, &insert, &[], &collecting) + .unwrap() + .pending + .unwrap(); + assert_eq!(pending.rows.len(), 2); + let decided = BeforeChange { + fingerprint: pending.fingerprint, + overrides: vec![set(1, &[("due", DataValue::Text("2024-07-01".into()))])], + }; + direct( + &c, + &insert, + &[], + &tasks(ActionKind::Insert, Before::Apply(decided)), + ) + .unwrap(); + let update = vec!["UPDATE tasks SET done = true WHERE id = 1".to_string()]; + let mut collecting = tasks(ActionKind::Update, Before::Collect); + collecting.dry_run = true; + let pending = direct(&c, &update, &[], &collecting) + .unwrap() + .pending + .unwrap(); + assert!(pending.rows[0].old.is_some()); + let decided = BeforeChange { + fingerprint: pending.fingerprint, + overrides: vec![set(0, &[("due", DataValue::Text("2024-07-02".into()))])], + }; + direct( + &c, + &update, + &[], + &tasks(ActionKind::Update, Before::Apply(decided)), + ) + .unwrap(); + let deleting = Job { + deleted: true, + ..tasks(ActionKind::Delete, Before::Off) + }; + let delete = vec!["DELETE FROM tasks WHERE id = 2".to_string()]; + let out = direct(&c, &delete, &[], &deleting).unwrap(); + assert_eq!(out.rows.deleted.len(), 1); + assert_eq!(out.rows.deleted[0].identity, [DataValue::Integer(2)]); + drop(c); + let due: Vec<(i64, Option)> = admin + .query( + &format!("SELECT id, due::text FROM {schema}.tasks ORDER BY id"), + &[], + ) + .unwrap() + .iter() + .map(|r| (r.get(0), r.get(1))) + .collect(); + assert_eq!( + due, + [ + (1, Some("2024-07-02".into())), + (3, None), + (4, Some("2024-07-01".into())) + ] + ); + admin + .batch_execute(&format!("DROP SCHEMA {schema} CASCADE")) + .unwrap(); +} diff --git a/src-tauri/src/queries/action_exec.rs b/src-tauri/src/queries/action_exec.rs index 9d54feb..44e988c 100644 --- a/src-tauri/src/queries/action_exec.rs +++ b/src-tauri/src/queries/action_exec.rs @@ -7,15 +7,16 @@ //! returns the changed rows, joined to the live table by rowid, so even a //! changed primary key is found; it never writes the file. `write_back` then //! writes them in one DuckDB transaction (see `write_back`). -use super::action::{ChangedRows, UpdatedRow, WORK}; +use super::action::{BeforeChange, ChangedRows, DeletedRow, Pending, UpdatedRow, WORK}; +use super::action_before; use crate::archive::ActionKind; use crate::data::logical::LogicalType; use crate::data::{self, DataValue, NamedValue}; use crate::manager::AppError; use duckdb::types::Value as DuckValue; -const BEFORE: &str = "temp.main.__ixtable_before"; -const ROWID: &str = "__ixtable_rowid"; +pub(super) const BEFORE: &str = "temp.main.__ixtable_before"; +pub(super) const ROWID: &str = "__ixtable_rowid"; /// What a run works on. pub struct Job { @@ -28,9 +29,33 @@ pub struct Job { /// Whether the table has a rowid (SQLite tables not declared WITHOUT ROWID). pub rowid: bool, pub kind: ActionKind, - /// Collect the changed rows for triggers. + /// Collect the created and updated rows for triggers. pub watch: bool, + /// Collect the deleted rows, with their values, for `deleted` triggers. + pub deleted: bool, + /// What before-change triggers need from this run. + pub before: Before, pub dry_run: bool, + /// The embedded SQLite file (else PostgreSQL). + pub sqlite: bool, + /// Each column's logical type, for normalizing trigger values. + pub logical: Vec<(String, LogicalType)>, + /// Generated columns, which an insert never names. + pub generated: Vec, + /// Columns with a default or identity: a new row's value can differ between calls. + pub defaulted: Vec, +} + +/// Before-change triggers' part in a run (`action_before`). +#[derive(Debug, Clone, Default)] +pub enum Before { + /// The table has none. + #[default] + Off, + /// Return the pending rows; the run is a dry run. + Collect, + /// Check the pending rows and apply the fields the triggers set. + Apply(BeforeChange), } #[derive(Debug, Default)] @@ -38,6 +63,8 @@ pub struct Outcome { pub changed: u64, pub removed: u64, pub rows: ChangedRows, + /// Rows for before-change triggers (`Before::Collect`). + pub pending: Option, } /// A computed embedded-SQLite UPDATE: the changes `write_back` still has to write. @@ -67,7 +94,7 @@ pub fn database_error(message: &str) -> AppError { AppError::new(code, message.to_string()) } -fn failed(e: duckdb::Error) -> AppError { +pub(super) fn failed(e: duckdb::Error) -> AppError { database_error(&e.to_string()) } @@ -92,7 +119,7 @@ fn in_transaction( Ok(out) } -fn run_statement( +pub(super) fn run_statement( connection: &duckdb::Connection, sql: &str, values: &[DuckValue], @@ -104,7 +131,7 @@ fn run_statement( .map_err(failed)? as u64) } -fn select_rows( +pub(super) fn select_rows( connection: &duckdb::Connection, sql: &str, ) -> Result<(Vec, Vec>), AppError> { @@ -124,7 +151,7 @@ fn select_rows( Ok((columns, rows)) } -fn list(columns: &[String], alias: &str) -> String { +pub(super) fn list(columns: &[String], alias: &str) -> String { columns .iter() .map(|c| format!("{alias}.{}", data::q(c))) @@ -132,14 +159,14 @@ fn list(columns: &[String], alias: &str) -> String { .join(", ") } -fn same_keys(keys: &[String]) -> String { +pub(super) fn same_keys(keys: &[String]) -> String { keys.iter() .map(|k| format!("t.{0} IS NOT DISTINCT FROM b.{0}", data::q(k))) .collect::>() .join(" AND ") } -fn any_differs(columns: &[String], new: &str, old: &str) -> String { +pub(super) fn any_differs(columns: &[String], new: &str, old: &str) -> String { columns .iter() .map(|c| format!("{new}.{0} IS DISTINCT FROM {old}.{0}", data::q(c))) @@ -160,12 +187,15 @@ pub fn direct( ) -> Result { in_transaction(connection, !job.dry_run, || { let table = &job.table; - if job.watch && matches!(job.kind, ActionKind::Insert | ActionKind::Update) { - // Updates compare every column; inserts only need the keys that existed. - let snapshot = match job.kind { - ActionKind::Update => "*".to_string(), - _ => list(&job.keys, "t"), - }; + let before = !matches!(job.before, Before::Off); + // Updates and deletes keep every column; inserts only need the keys that existed. + let snapshot = match job.kind { + ActionKind::Update if job.watch || before => Some("*".to_string()), + ActionKind::Delete | ActionKind::Replace if job.deleted => Some("*".to_string()), + ActionKind::Insert if job.watch || before => Some(list(&job.keys, "t")), + _ => None, + }; + if let Some(snapshot) = snapshot { connection .execute_batch(&format!( "CREATE OR REPLACE TEMP TABLE __ixtable_before AS SELECT {snapshot} FROM {table} t" @@ -181,7 +211,7 @@ pub fn direct( [n] => (0, *n), _ => (0, 0), }; - let rows = match (job.watch, job.kind) { + let mut rows = match (job.watch, job.kind) { (false, _) | (_, ActionKind::Delete) => ChangedRows::default(), (true, ActionKind::Update) => updated_since_snapshot(connection, job)?, (true, ActionKind::Replace) => ChangedRows { @@ -190,7 +220,7 @@ pub fn direct( &format!("SELECT {} FROM {table} t", list(&job.keys, "t")), )? .1, - updated: vec![], + ..Default::default() }, (true, ActionKind::Insert) => ChangedRows { created: select_rows( @@ -202,17 +232,60 @@ pub fn direct( ), )? .1, - updated: vec![], + ..Default::default() }, }; + if job.deleted { + rows.deleted = deleted_since_snapshot(connection, job)?; + } + let pending = match &job.before { + Before::Off => None, + Before::Collect => Some(action_before::pending_direct(connection, job)?), + Before::Apply(decided) => { + let pending = action_before::pending_direct(connection, job)?; + action_before::verify(decided, &pending)?; + action_before::apply_direct(connection, job, decided, &pending)?; + None + } + }; Ok(Outcome { changed, removed, rows, + pending, }) }) } +/// Rows of the snapshot that are gone after a delete (every row, for a replace). +fn deleted_since_snapshot( + connection: &duckdb::Connection, + job: &Job, +) -> Result, AppError> { + let columns = columns_of(connection, BEFORE)?; + let sql = match job.kind { + ActionKind::Replace => format!("SELECT {} FROM {BEFORE} b", list(&columns, "b")), + _ => format!( + "SELECT {} FROM {BEFORE} b ANTI JOIN {} t ON {}", + list(&columns, "b"), + job.table, + same_keys(&job.keys) + ), + }; + Ok(select_rows(connection, &sql)? + .1 + .into_iter() + .map(|row| DeletedRow { + identity: job + .keys + .iter() + .filter_map(|k| columns.iter().position(|c| c == k).map(|i| row[i].clone())) + .collect(), + values: named(&columns, &row), + }) + .collect()) +} + /// Rows with the same key and some different column, with their old values. fn updated_since_snapshot( connection: &duckdb::Connection, @@ -239,12 +312,12 @@ fn updated_since_snapshot( }) .collect(); Ok(ChangedRows { - created: vec![], updated, + ..Default::default() }) } -fn named(columns: &[String], values: &[DataValue]) -> Vec { +pub(super) fn named(columns: &[String], values: &[DataValue]) -> Vec { columns .iter() .zip(values) @@ -287,6 +360,16 @@ pub fn compute_update( )) .map_err(failed)?; let changed = run_statement(connection, sql, values)?; + let pending = match &job.before { + Before::Off => None, + Before::Collect => Some(action_before::pending_copy(connection, job, &columns)?), + Before::Apply(decided) => { + let pending = action_before::pending_copy(connection, job, &columns)?; + action_before::verify(decided, &pending)?; + action_before::apply_copy(connection, job, decided, &pending)?; + None + } + }; // The key that finds the row: its rowid, else its old primary key. let type_of = |c: &str| { columns @@ -363,8 +446,8 @@ pub fn compute_update( }; let rows = if job.watch { ChangedRows { - created: vec![], updated, + ..Default::default() } } else { ChangedRows::default() @@ -374,13 +457,14 @@ pub fn compute_update( changed, removed: 0, rows, + pending, }, changes, }) }) } -fn column_types( +pub(super) fn column_types( connection: &duckdb::Connection, table: &str, ) -> Result<(Vec, Vec), AppError> { diff --git a/src-tauri/src/queries/action_tests.rs b/src-tauri/src/queries/action_tests.rs index 6e1a663..c22ca53 100644 --- a/src-tauri/src/queries/action_tests.rs +++ b/src-tauri/src/queries/action_tests.rs @@ -137,7 +137,7 @@ fn embedded_sqlite_updates_run_on_a_copy_under_the_table_name() { } /// A workspace with `data.db` holding customers and orders. -fn workspace() -> std::path::PathBuf { +pub(crate) fn workspace() -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("ixtable-action-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); let c = rusqlite::Connection::open(dir.join("data.db")).unwrap(); @@ -153,12 +153,12 @@ fn workspace() -> std::path::PathBuf { dir } -fn writer(dir: &std::path::Path) -> duckdb::Connection { +pub(crate) fn writer(dir: &std::path::Path) -> duckdb::Connection { let extension = data::extensions::sqlite_extension_path().unwrap(); data::write::open_writer(dir, &extension, &data::read::ReadTarget::Sqlite, false).unwrap() } -fn rows(dir: &std::path::Path, sql: &str) -> Vec> { +pub(crate) fn rows(dir: &std::path::Path, sql: &str) -> Vec> { let c = rusqlite::Connection::open(dir.join("data.db")).unwrap(); let mut stmt = c.prepare(sql).unwrap(); let n = stmt.column_count(); @@ -170,7 +170,7 @@ fn rows(dir: &std::path::Path, sql: &str) -> Vec> { const ORDERS: &str = "data.\"main\".\"orders\""; -fn job(kind: ActionKind, watch: bool, dry_run: bool) -> Job { +pub(crate) fn job(kind: ActionKind, watch: bool, dry_run: bool) -> Job { Job { table: ORDERS.into(), name: "orders".into(), @@ -179,10 +179,16 @@ fn job(kind: ActionKind, watch: bool, dry_run: bool) -> Job { kind, watch, dry_run, + deleted: false, + before: Default::default(), + sqlite: true, + logical: vec![], + generated: vec![], + defaulted: vec![], } } -fn copy_sql(sql: &str, table: &str) -> String { +pub(crate) fn copy_sql(sql: &str, table: &str) -> String { let query = SavedQuery { sql: sql.into(), ..Default::default() @@ -458,6 +464,12 @@ fn postgres_action_queries_write_through_the_attachment() { kind, watch: true, dry_run, + deleted: false, + before: Default::default(), + sqlite: false, + logical: vec![], + generated: vec![], + defaulted: vec![], }; let query = SavedQuery { sql: "UPDATE visits SET day = day + 1, seen = coalesce(seen, TIMESTAMP '2024-03-01 08:00'), paid = true WHERE total < $max".into(), diff --git a/src-tauri/src/queries/mod.rs b/src-tauri/src/queries/mod.rs index 1a3380e..990d9c2 100644 --- a/src-tauri/src/queries/mod.rs +++ b/src-tauri/src/queries/mod.rs @@ -189,6 +189,9 @@ pub fn prepare_sql(sql: &str) -> Result { } pub mod action; +pub(crate) mod action_before; +#[cfg(test)] +mod action_before_tests; pub(crate) mod action_exec; #[cfg(test)] pub(crate) mod action_tests; diff --git a/src-tauri/src/recordstore/commands.rs b/src-tauri/src/recordstore/commands.rs index 6e79472..50ab559 100644 --- a/src-tauri/src/recordstore/commands.rs +++ b/src-tauri/src/recordstore/commands.rs @@ -342,11 +342,11 @@ fn op_kind(op: &WriteOp) -> crate::authz::Op { WriteOp::Delete { .. } => crate::authz::Op::Delete, } } -fn trigger_event(op: &WriteOp) -> Option { +fn trigger_event(op: &WriteOp) -> crate::automation::TriggerEvent { match op { - WriteOp::Insert { .. } => Some(crate::automation::TriggerEvent::Created), - WriteOp::Update { .. } => Some(crate::automation::TriggerEvent::Updated), - WriteOp::Delete { .. } => None, + WriteOp::Insert { .. } => crate::automation::TriggerEvent::Created, + WriteOp::Update { .. } => crate::automation::TriggerEvent::Updated, + WriteOp::Delete { .. } => crate::automation::TriggerEvent::Deleted, } } @@ -370,8 +370,10 @@ fn authorize_ops( for (i, op) in ops.iter().enumerate() { let auth = triggers.get(i).and_then(Option::as_ref); crate::trigger_auth::authorize(window, op.table(), op_kind(op), auth)?; - if let Some(event) = trigger_event(op) { - crate::trigger_auth::precheck(window, op.table(), event)?; + crate::trigger_auth::precheck(window, op.table(), trigger_event(op))?; + if !matches!(op, WriteOp::Delete { .. }) { + let before = crate::automation::TriggerEvent::BeforeChange; + crate::trigger_auth::precheck(window, op.table(), before)?; } } Ok(()) @@ -388,9 +390,8 @@ fn with_grants( .iter() .zip(outcomes) .map(|(op, outcome)| { - let trigger_grant = trigger_event(op).and_then(|event| { - crate::trigger_auth::issue(window, &config, op.table(), event, now) - }); + let trigger_grant = + crate::trigger_auth::issue(window, &config, op.table(), trigger_event(op), now); TriggeredOutcome { outcome, trigger_grant, diff --git a/src/automation/StepEditor.tsx b/src/automation/StepEditor.tsx index 4041d11..9b4deb2 100644 --- a/src/automation/StepEditor.tsx +++ b/src/automation/StepEditor.tsx @@ -389,6 +389,19 @@ function StepFields({ {when} ); + case "setField": + return ( +
+ set({ field: v })} /> + set({ value: v })} + /> + {when} +
+ ); case "condition": return ( <> diff --git a/src/automation/TriggersPanel.tsx b/src/automation/TriggersPanel.tsx index 8e2d11d..fb48dfe 100644 --- a/src/automation/TriggersPanel.tsx +++ b/src/automation/TriggersPanel.tsx @@ -12,6 +12,7 @@ export function TriggersPanel({ focusId }: { focusId?: string }) { const triggers = config.triggers ?? []; const [selected, setSelected] = useState(focusId ?? triggers[0]?.id ?? null); const trigger = triggers.find((t) => t.id === selected) ?? null; + const before = trigger?.event === "beforeChange"; const enabledId = useId(); const tables = objects .filter((o) => o.objectType === "table" && !o.name.startsWith("_ixtable_")) @@ -102,10 +103,14 @@ export function TriggersPanel({ focusId }: { focusId?: string }) { edit({ event })} + onChange={(event) => + edit(event === "beforeChange" ? { event, mode: "sync" } : { event }) + } options={[ + { value: "beforeChange", label: "Before a record is saved" }, { value: "created", label: "Record created" }, { value: "updated", label: "Record updated" }, + { value: "deleted", label: "Record deleted" }, ]} /> -
- edit({ mode })} - options={[ - { value: "sync", label: "Synchronously, inside the write" }, - { value: "async", label: "Asynchronously, on the job queue" }, - ]} - /> - {trigger.mode === "async" && ( - <> - edit({ maxAttempts })} - /> - edit({ backoffMs })} - /> - - )} -
- {trigger.mode === "async" && ( + {before ? ( +

+ Runs before each new or changed record is saved, including rows an action query + writes. Its action can set fields with Set field steps, read queries, and reject the + save with a Fail step; it cannot write records or open anything. In the condition and + action, old is the row before an update and empty for a new record. Run + as app skips the user's role checks; run as the signed-in user refuses the save up + front when the user's role cannot run the trigger. +

+ ) : ( +
+ edit({ mode })} + options={[ + { value: "sync", label: "Synchronously, inside the write" }, + { value: "async", label: "Asynchronously, on the job queue" }, + ]} + /> + {trigger.mode === "async" && ( + <> + edit({ maxAttempts })} + /> + edit({ backoffMs })} + /> + + )} +
+ )} + {!before && trigger.mode === "async" && ( edit({ idempotencyKey: idempotencyKey || undefined })} /> )} -

- Sync triggers run after the record is saved, as part of the same operation; a failing - action reports an error to whoever saved the record, but the save stays. Async triggers - run in the background while the app is open, with retries. Run as app lets the trigger - make its own writes even when the user's role cannot; run as the signed-in user refuses - the save up front when the user's role cannot run the trigger. -

+ {!before && ( +

+ Created, updated and deleted triggers see the record as record (a deleted + record as it was). Sync triggers run after the record is saved, as part of the same + operation; a failing action reports an error to whoever saved the record, but the save + stays. Async triggers run in the background while the app is open, with retries. Run + as app lets the trigger make its own writes even when the user's role cannot; run as + the signed-in user refuses the save up front when the user's role cannot run the + trigger. +

+ )} ) : (
diff --git a/src/automation/runner.ts b/src/automation/runner.ts index d2a8b0f..efdc646 100644 --- a/src/automation/runner.ts +++ b/src/automation/runner.ts @@ -45,7 +45,16 @@ import * as queryApi from "../query/api"; import { ACTION_QUERY_OPS, type ActionSpec } from "../query/types"; import { customActionFor, customScope } from "./custom"; import { afterWrite, currentRow, type FoundRow, matchRows, rowKey, withKeys } from "./rows"; -import type { ActionDef, MatchSpec, OnError, Step, StepLog, ValueMap } from "./types"; +import { STEP_LABELS } from "./steps"; +import { + type ActionDef, + BEFORE_CHANGE_STEPS, + type MatchSpec, + type OnError, + type Step, + type StepLog, + type ValueMap, +} from "./types"; import { rowToObject, toNamedValues } from "./values"; export { ActionPicker } from "./ActionPicker"; @@ -87,6 +96,11 @@ export interface ActionContext { snapshot?: Record; /** Original values a write of this row must match (custom actions: what the caller edited). */ current?: { table: string; identity: DataValue[]; expected: NamedValue[] }; + /** + * Set while a before-change trigger runs: setField steps collect the record's + * new field values in `set`, and steps that write or touch the UI are refused. + */ + beforeChange?: { set: Record }; } export interface ActionResult { @@ -94,6 +108,8 @@ export interface ActionResult { error?: string; /** True when a confirm step was declined. */ cancelled?: boolean; + /** True when a fail step ended the action (`error` is its message). */ + aborted?: boolean; steps: StepLog[]; /** Values stored by steps with `storeAs`. */ results: Record; @@ -342,6 +358,7 @@ function effect(frame: Frame, run: () => void) { async function runStep(step: Step, frame: Frame, path: string): Promise { const { ctx, scope } = frame; + if (ctx.beforeChange) refuseInBeforeChange(step, ctx.config); switch (step.kind) { case "createRecord": { authorize(frame, "table", step.table, "create"); @@ -521,11 +538,28 @@ async function runStep(step: Step, frame: Frame, path: string): Promise { const text = expr(step.message, frame, "Failure message"); throw new Aborted(String(text ?? "") || "The action failed"); } + case "setField": { + if (!ctx.beforeChange) throw new Error("Set field steps only run in before-change triggers"); + if (!step.field?.trim()) throw new Error("Field is not set"); + const value = expr(step.value, frame, `Value for ${step.field}`); + ctx.beforeChange.set[step.field] = value; + scope.record = { ...(scope.record as Record | null), [step.field]: value }; + return; + } default: throw new Error(`Unknown step kind ${(step as { kind: string }).kind}`); } } +/** A before-change trigger only computes fields or rejects: it never writes or touches the UI. */ +function refuseInBeforeChange(step: Step, config: DocumentConfig) { + const actionQuery = + step.kind === "runQuery" && config.savedQueries.some((q) => q.id === step.queryId && q.action); + if (BEFORE_CHANGE_STEPS.includes(step.kind) && !actionQuery) return; + const what = actionQuery ? "Run action query" : STEP_LABELS[step.kind]; + throw new Aborted(`A before-change trigger cannot run "${what}" steps; use an after trigger`); +} + function exists(config: DocumentConfig, kind: string, id: string) { if (!id) throw new Error(`No ${kind} selected`); const list: { id: string }[] | undefined = diff --git a/src/automation/steps.ts b/src/automation/steps.ts index 5777011..35d0aa8 100644 --- a/src/automation/steps.ts +++ b/src/automation/steps.ts @@ -16,6 +16,7 @@ export const STEP_LABELS: Record = { condition: "If / else", runAction: "Run action", fail: "Fail with message", + setField: "Set field (before-change)", }; export function newStep(kind: StepKind): Step { @@ -49,5 +50,7 @@ export function newStep(kind: StepKind): Step { return { id, kind, actionId: "" }; case "fail": return { id, kind, message: "" }; + case "setField": + return { id, kind, field: "", value: "" }; } } diff --git a/src/automation/triggers.ts b/src/automation/triggers.ts index bba4591..228264a 100644 --- a/src/automation/triggers.ts +++ b/src/automation/triggers.ts @@ -1,7 +1,13 @@ /** * Record triggers (PRD §17.3), hooked into src/lib/records.ts. * - * Sync triggers run their action after the initiating write commits, inside the + * Before-change triggers run before a create or update is sent: their actions + * may set fields of the record (setField) or reject the save (a fail step or + * any failing step), and cannot write records, so a rejection writes nothing. + * Action queries hand them each pending row through `beforeBulk`. + * + * Created, updated and deleted triggers run after the write commits. Sync + * triggers run their action after the initiating write commits, inside the * same workflow: the caller's insertRecord/updateRecord awaits them and a failing * trigger makes that call reject. The write itself is already committed then; * rollback-mode actions only undo their own writes. @@ -18,7 +24,9 @@ import { evaluate, evaluateBoolean } from "../expr"; import { inspectTable, readTablePage } from "../lib/api"; import { type BulkChange, + type PendingRow, type RecordWrite, + type RowOverride, registerRecordHook, type WriteExtra, } from "../lib/records"; @@ -27,7 +35,14 @@ import type { DataValue, DocumentConfig, Filter } from "../lib/types"; import { enqueueJob, notifyJobsChanged, releaseTriggerGrant } from "./api"; import { type ActionContext, runAction } from "./runner"; import type { Trigger, TriggerEvent } from "./types"; -import { fromDataValue, namedToObject, rowToObject, stableHash, toDataValue } from "./values"; +import { + fromDataValue, + namedToObject, + rowToObject, + stableHash, + toDataValue, + toNamedValues, +} from "./values"; export const MAX_TRIGGER_DEPTH = 5; @@ -56,19 +71,36 @@ export interface TriggerEnv { const enabledFor = (config: DocumentConfig, table: string, event: TriggerEvent) => (config.triggers ?? []).filter((t) => t.enabled && t.table === table && t.event === event); +/** Thrown when a before-change trigger rejects a save; nothing was written. */ +export class BeforeChangeRejected extends Error {} + /** * Registers the trigger hook on record writes; returns its unregister function. - * Before an update that has `updated` triggers, it reads the row so `old` is - * available even when the caller did not pass `meta.old`. + * Before an update with `updated` or `beforeChange` triggers, or a delete with + * `deleted` triggers, it reads the row so `old` is available even when the + * caller did not pass `meta.old`. Before-change triggers then decide the write. */ export function installTriggers(env: TriggerEnv): () => void { const previous = new WeakMap | null>(); return registerRecordHook({ before: async (write) => { - if (write.operation !== "update" || write.meta?.old || !write.identity) return; - if (!enabledFor(env.getConfig(), write.table, "updated").length) return; - previous.set(write, await readRow(write.table, write.identity)); + const config = env.getConfig(); + const has = (event: TriggerEvent) => enabledFor(config, write.table, event).length > 0; + const needsOld = + write.operation === "update" + ? has("updated") || has("beforeChange") + : write.operation === "delete" && has("deleted"); + const old = + write.meta?.old ?? + (needsOld && write.identity ? await readRow(write.table, write.identity) : null); + const next = + write.operation === "delete" || !has("beforeChange") + ? write + : await beforeChange(write, old, env); + if (needsOld && !write.meta?.old) previous.set(next, old); + return next; }, + beforeBulk: (table, rows, depth) => beforeChangeBulk(table, rows, depth, env), after: (write, result, extra) => write.meta?.bulk ? dispatchBulk(write, write.meta.bulk, env) @@ -108,7 +140,88 @@ export async function dispatchTriggers( } /** - * Fires an action query's triggers once per created or updated row, in order, + * Runs the before-change triggers of a create or update: returns the write with + * the fields they set, or throws BeforeChangeRejected. + */ +async function beforeChange( + write: RecordWrite, + old: Record | null, + env: TriggerEnv, +): Promise { + const values = namedToObject(write.values); + const record = write.operation === "update" ? { ...old, ...values } : values; + const isNew = write.operation === "insert"; + const set = await decide(write.table, record, isNew ? null : old, write.meta?.triggerDepth, env); + if (!Object.keys(set).length) return write; + return { + ...write, + values: [...write.values.filter((v) => !(v.column in set)), ...toNamedValues(set)], + }; +} + +/** Runs the before-change triggers on each row an action query is about to write. */ +async function beforeChangeBulk( + table: string, + rows: PendingRow[], + depth: number, + env: TriggerEnv, +): Promise { + if (!enabledFor(env.getConfig(), table, "beforeChange").length) return []; + const overrides: RowOverride[] = []; + for (const [i, row] of rows.entries()) { + const old = row.old ? namedToObject(row.old) : null; + const set = await decide(table, namedToObject(row.values), old, depth, env); + if (Object.keys(set).length) overrides.push({ row: i, values: toNamedValues(set) }); + } + return overrides; +} + +/** + * Runs a table's before-change triggers in order on `record` (each sees the + * fields earlier ones set) and returns the fields they set. A failing action + * rejects: a fail step with its own message, anything else naming the trigger. + */ +async function decide( + table: string, + record: Record, + old: Record | null, + triggerDepth: number | undefined, + env: TriggerEnv, +): Promise> { + const set: Record = {}; + const app = env.app?.() ?? {}; + for (const trigger of enabledFor(env.getConfig(), table, "beforeChange")) { + const current = { ...record, ...set }; + if ( + trigger.condition?.trim() && + !evaluateBoolean(trigger.condition, { record: current, old, app }) + ) + continue; + const outcome = await runAction( + trigger.actionId, + env.context({ + record: current, + old: old ?? undefined, + triggerDepth: triggerDepth ?? 0, + beforeChange: { set }, + // App mode skips the role's checks; it holds no grant, since the action cannot write. + ...(trigger.runAs !== "user" && { triggerAuth: { triggerId: trigger.id, grant: "" } }), + }), + ); + if (!outcome.ok) + throw new BeforeChangeRejected( + `Not saved: ${ + outcome.aborted + ? outcome.error + : `trigger "${trigger.name}" failed: ${outcome.error ?? "unknown error"}` + }`, + ); + } + return set; +} + +/** + * Fires an action query's triggers once per created, updated or deleted row, in order, * as if each row had been written alone. The rows of one event share Rust's * grant, released when all of them have run. Failures are collected so one * bad row does not stop the others' triggers. @@ -148,8 +261,24 @@ async function dispatchBulk(write: RecordWrite, bulk: BulkChange, env: TriggerEn 1, bulk.updatedGrant ?? undefined, ); + for (const [i, row] of (bulk.deleted ?? []).entries()) + await each( + { + operation: "delete", + table: write.table, + values: [], + identity: row.identity, + meta: { + ...base, + writeId: `${write.meta?.writeId}:d${i}`, + old: namedToObject(row.values), + }, + }, + 1, + bulk.deletedGrant ?? undefined, + ); } finally { - for (const grant of [bulk.createdGrant, bulk.updatedGrant]) + for (const grant of [bulk.createdGrant, bulk.updatedGrant, bulk.deletedGrant]) if (grant) await releaseTriggerGrant(grant).catch(() => undefined); } if (failures.length) throw new Error(failures.join("; ")); @@ -162,8 +291,8 @@ async function dispatch( oldRow: Record | undefined, grant: string | undefined, ): Promise { - if (write.operation === "delete") return; - const event: TriggerEvent = write.operation === "insert" ? "created" : "updated"; + const event: TriggerEvent = + write.operation === "insert" ? "created" : write.operation === "update" ? "updated" : "deleted"; const triggers = enabledFor(env.getConfig(), write.table, event); if (!triggers.length) return; const depth = (write.meta?.triggerDepth ?? 0) + 1; @@ -174,11 +303,15 @@ async function dispatch( const identity = (write.operation === "insert" ? (result as DataValue[] | null) : write.identity) ?? []; const values = namedToObject(write.values); - const record = (await readRow(write.table, identity)) ?? { - ...values, - ...(identity.length === 1 && { rowid: fromDataValue(identity[0]) }), - }; const old = write.meta?.old ?? oldRow ?? null; + // A deleted row is gone: its triggers see the values it had. + const record = + event === "deleted" + ? (old ?? {}) + : ((await readRow(write.table, identity)) ?? { + ...values, + ...(identity.length === 1 && { rowid: fromDataValue(identity[0]) }), + }); const app = env.app?.() ?? {}; for (const trigger of triggers) { const scope = { record, old, app }; diff --git a/src/automation/types.ts b/src/automation/types.ts index 77ebac3..e12d0a7 100644 --- a/src/automation/types.ts +++ b/src/automation/types.ts @@ -33,7 +33,9 @@ export type StepBody = | { kind: "condition"; then: Step[]; else: Step[] } | { kind: "runAction"; actionId: string } /** Business-rule abort: ends the action with ok: false and this message (nothing commits under rollback). */ - | { kind: "fail"; message: Expr }; + | { kind: "fail"; message: Expr } + /** Before-change triggers only: sets a field of the record about to be saved. */ + | { kind: "setField"; field: string; value: Expr }; export type StepKind = StepBody["kind"]; @@ -55,14 +57,31 @@ export interface ActionDef { onError: OnError; } -export type TriggerEvent = "created" | "updated"; +/** + * created/updated/deleted run after the write commits. beforeChange runs before a + * create or update is written (always sync): its action may set fields of the + * record (setField) or reject the save (fail, or any failing step). + */ +export type TriggerEvent = "created" | "updated" | "deleted" | "beforeChange"; + +/** The step kinds a before-change trigger's action may use (mirrors automation.rs). */ +export const BEFORE_CHANGE_STEPS: readonly StepKind[] = [ + "setField", + "condition", + "fail", + "runQuery", + "runAction", +]; export interface Trigger { id: string; name: string; table: string; event: TriggerEvent; - /** Optional expression over `record` (and `old` for updates); false/null skips the trigger. */ + /** + * Optional expression over `record` (and `old`: the row before an update, the + * deleted row, null on a before-change create); false/null skips the trigger. + */ condition?: Expr; actionId: string; /** sync: runs inside the initiating write workflow. async: enqueued on the durable local job queue. */ @@ -138,4 +157,5 @@ export const STEP_KINDS: StepKind[] = [ "condition", "runAction", "fail", + "setField", ]; diff --git a/src/lib/records.ts b/src/lib/records.ts index ec31953..98d3ea7 100644 --- a/src/lib/records.ts +++ b/src/lib/records.ts @@ -36,13 +36,31 @@ export interface RecordWriteMeta { bulk?: BulkChange; } -/** The rows an action query created or updated, for firing triggers once per row. */ +/** The rows an action query created, updated or deleted, for firing triggers once per row. */ export interface BulkChange { created: DataValue[][]; updated: { identity: DataValue[]; old: NamedValue[] }[]; + /** Deleted rows with every column's value before the statement. */ + deleted?: { identity: DataValue[]; values: NamedValue[] }[]; /** Sync app-mode trigger grants Rust issued for each event; shared by its rows. */ createdGrant?: string | null; updatedGrant?: string | null; + deletedGrant?: string | null; +} + +/** A row an action query is about to create or update, for before-change triggers. */ +export interface PendingRow { + identity: DataValue[]; + /** Every column's new value. */ + values: NamedValue[]; + /** Every column's value before an update; null for a created row. */ + old: NamedValue[] | null; +} + +/** Fields before-change triggers set on one pending row, by its position in `pending.rows`. */ +export interface RowOverride { + row: number; + values: NamedValue[]; } /** What `run_action_query` returns (src-tauri/src/queries/action.rs). */ @@ -53,6 +71,8 @@ export interface ActionQueryRun extends BulkChange { removed: number; dryRun: boolean; table: string; + /** Rows awaiting before-change triggers: nothing was written yet (see `runActionQuery`). */ + pending?: { fingerprint: string; rows: PendingRow[] }; } /** What an app-mode trigger presents to Rust: its sync grant, or its job lease. */ @@ -99,8 +119,10 @@ export function setRecordRouter(next: RecordRouter): () => void { } export interface RecordHook { - // Runs before the write; throwing aborts it. - before?: (write: RecordWrite) => void | Promise; + // Runs before the write; throwing aborts it, returning a write replaces it (before-change triggers). + before?: (write: RecordWrite) => void | RecordWrite | Promise; + // Runs before an action query writes its rows (before-change triggers); throwing aborts the query. + beforeBulk?: (table: string, rows: PendingRow[], triggerDepth: number) => Promise; // Runs after the write commits, with the command result. after?: (write: RecordWrite, result: unknown, extra?: WriteExtra) => void | Promise; } @@ -184,20 +206,28 @@ async function write( if (routed) return (await routed) as T; } const active = [...hooks]; - for (const hook of active) await hook.before?.(record); - const outcome = await send(record); + const final = await beforeAll(record, active); + const outcome = await send(final); announceChange(); - const [result] = await afterCommit([record], [outcome], active); + const [result] = await afterCommit([final], [outcome], active); return result as T; } +/** Runs the before hooks in turn; each may replace the write. */ +async function beforeAll(record: RecordWrite, active: RecordHook[]): Promise { + let current = record; + for (const hook of active) current = (await hook.before?.(current)) ?? current; + return current; +} + const trigger = (record: RecordWrite) => record.meta?.trigger ?? null; /** Inserts a row and resolves with the new row's identity values. */ export const insertRecord = (table: string, values: NamedValue[], meta?: RecordWriteMeta) => write( { operation: "insert", table, values, identity: null, ...(meta && { meta }) }, - (record) => call("insert_row", { table, values, trigger: trigger(record) }), + (record) => + call("insert_row", { table, values: record.values, trigger: trigger(record) }), ); /** @@ -213,7 +243,7 @@ export const updateRecord = ( write({ operation: "update", table, values, identity, ...(meta && { meta }) }, (record) => call("update_row", { table, - values, + values: record.values, identity, expected: meta?.expected ?? null, trigger: trigger(record), @@ -242,9 +272,9 @@ export const deleteRecord = (table: string, identity: DataValue[], meta?: Record * actions here; the action runner routes them before batching. */ export async function writeRecordBatch(input: RecordWrite[]): Promise { - const writes = input.map(withWriteId); const active = [...hooks]; - for (const record of writes) for (const hook of active) await hook.before?.(record); + const writes: RecordWrite[] = []; + for (const record of input.map(withWriteId)) writes.push(await beforeAll(record, active)); const outcomes = await call("execute_write_batch", { ops: writes.map(toOp), triggers: writes.map((w) => w.meta?.trigger ?? null), @@ -255,22 +285,33 @@ export async function writeRecordBatch(input: RecordWrite[]): Promise /** * Runs a saved action query (docs/decisions/action-queries.md). With `dryRun` - * the changes roll back and only the counts come back. Otherwise every record - * hook runs once with a single write that stands for the whole query (its - * `meta.bulk` lists the created and updated rows); the trigger hook fires the - * table's triggers once per row. A failing trigger rejects with a - * CommittedWriteError: the query's changes are saved. + * the changes roll back and only the counts come back. On a table with + * before-change triggers the first call writes nothing and returns the pending + * rows: the `beforeBulk` hooks decide on them (a rejection ends the run with + * nothing written), and a second call writes the query with the fields they + * set. Then every record hook runs once with a single write that stands for + * the whole query (its `meta.bulk` lists the created, updated and deleted + * rows); the trigger hook fires the table's triggers once per row. A failing + * trigger rejects with a CommittedWriteError: the query's changes are saved. */ export async function runActionQuery( queryId: string, params: NamedValue[], options: { dryRun?: boolean; triggerDepth?: number } = {}, ): Promise { - const run = await call("run_action_query", { - id: queryId, - params, - dryRun: options.dryRun ?? false, - }); + const request = { id: queryId, params, dryRun: options.dryRun ?? false }; + let run = await call("run_action_query", request); + if (run.pending) { + const active = [...hooks]; + const overrides: RowOverride[] = []; + for (const hook of active) + overrides.push( + ...((await hook.beforeBulk?.(run.table, run.pending.rows, options.triggerDepth ?? 0)) ?? + []), + ); + const before = { fingerprint: run.pending.fingerprint, overrides }; + run = await call("run_action_query", { ...request, before }); + } if (run.dryRun) return run; announceChange(); const operation: RecordOperation = run.updated.length ? "update" : "insert"; diff --git a/tests/integration/before-change-triggers.test.tsx b/tests/integration/before-change-triggers.test.tsx new file mode 100644 index 0000000..0f18ae3 --- /dev/null +++ b/tests/integration/before-change-triggers.test.tsx @@ -0,0 +1,231 @@ +import { screen } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import { expect, it } from "vitest"; +import { deleteRecord, insertRecord, runActionQuery } from "../../src/lib/records"; +import { createTable, insertRow, readPage, renderNewDocument, value } from "./helpers"; + +const LONG = { timeout: 20_000 }; +type User = Awaited>; +const text = (v: string) => value("text", v); +const today = () => new Date().toISOString().slice(0, 10); + +async function setup() { + await createTable("orders", [ + { name: "id", declaredType: "INTEGER", primaryKeyPosition: 1 }, + { name: "total", declaredType: "REAL" }, + { name: "due", declaredType: "DATE" }, + { name: "status", declaredType: "TEXT" }, + ]); + await createTable("audit", [ + { name: "id", declaredType: "INTEGER", primaryKeyPosition: 1 }, + { name: "message", declaredType: "TEXT" }, + ]); + for (const [id, total] of [ + [1, 10], + [2, 20], + [3, 30], + ] as const) + await insertRow("orders", [ + { column: "id", value: value("integer", id) }, + { column: "total", value: value("real", total) }, + { column: "due", value: { type: "date", value: "2024-01-31" } }, + { column: "status", value: text("open") }, + ]); +} + +// Applies config changes the way the other integration tests do, then reloads the Entities tab. +async function editConfig(user: User, extra: (config: Record) => object) { + await user.click(screen.getByRole("button", { name: "Settings" })); + await user.click(await screen.findByRole("tab", { name: "Entities" }, LONG)); + await screen.findByRole("combobox", { name: "Concurrency policy for orders" }, LONG); + const config = await invoke>("read_document_config", { + windowLabel: "main", + }); + await invoke("update_document_config", { + windowLabel: "main", + config: { ...config, ...extra(config) }, + }); + await user.click(screen.getByRole("tab", { name: "Datasource" })); + await user.click(screen.getByRole("tab", { name: "Entities" })); + await screen.findByRole("heading", { name: "Entities" }, LONG); +} + +const action = (id: string, steps: Array>) => ({ + id, + name: id, + onError: "stop", + steps: steps.map((s, i) => ({ id: `${id}-${i}`, ...s })), +}); +const trigger = (id: string, event: string, actionId: string, extra: object = {}) => ({ + id, + name: id, + table: "orders", + event, + actionId, + mode: "sync", + enabled: true, + maxAttempts: 1, + backoffMs: 0, + ...extra, +}); +const column = async (table: string, name: string) => { + const page = await readPage(table); + const index = page.columns.findIndex((c) => c.name === name); + return page.rows.map((r) => r[index]); +}; +const auditMessages = async () => (await column("audit", "message")).map((v) => v.value).sort(); + +it("stores a field set by a before-change trigger on insert and rejects without a row", async () => { + const user = await renderNewDocument(); + await setup(); + await editConfig(user, () => ({ + actions: [ + action("a-stamp", [ + { kind: "setField", field: "status", value: "upper(record.status)" }, + { kind: "fail", message: "'Total must be positive'", when: "record.total <= 0" }, + ]), + ], + triggers: [trigger("t-before", "beforeChange", "a-stamp")], + })); + await insertRecord("orders", [ + { column: "id", value: value("integer", 4) }, + { column: "total", value: value("real", 5) }, + { column: "status", value: text("new") }, + ]); + expect((await column("orders", "status"))[3]).toEqual(text("NEW")); + + await expect( + insertRecord("orders", [ + { column: "id", value: value("integer", 5) }, + { column: "total", value: value("real", -1) }, + { column: "status", value: text("bad") }, + ]), + ).rejects.toThrow("Not saved: Total must be positive"); + expect((await readPage("orders")).total).toBe(4); +}); + +it("writes an audit row from a deleted trigger with the deleted row's values", async () => { + const user = await renderNewDocument(); + await setup(); + await editConfig(user, () => ({ + actions: [ + action("a-audit", [ + { + kind: "createRecord", + table: "audit", + values: { message: "'deleted ' & record.id & ' ' & record.status & ' ' & old.total" }, + }, + ]), + ], + triggers: [trigger("t-del", "deleted", "a-audit")], + })); + await deleteRecord("orders", [value("integer", 2)], { + expected: [ + { column: "id", value: value("integer", 2) }, + { column: "total", value: value("real", 20) }, + { column: "due", value: { type: "date", value: "2024-01-31" } }, + { column: "status", value: text("open") }, + ], + }); + expect((await readPage("orders")).total).toBe(2); + expect(await auditMessages()).toEqual(["deleted 2 open 20"]); +}); + +it("applies before-change fields to every row of an update action query, or rejects it", async () => { + const user = await renderNewDocument(); + await setup(); + await editConfig(user, () => ({ + savedQueries: [ + { + id: "q-close", + name: "Close orders", + sql: "UPDATE orders SET status = 'closed' WHERE total < $max", + parameters: [{ name: "max", logicalType: "number" }], + action: { kind: "update", table: "orders" }, + }, + ], + actions: [ + action("a-date", [ + { kind: "setField", field: "due", value: "today()" }, + { kind: "fail", message: "'Orders over 25 stay open'", when: "record.total > 25" }, + ]), + ], + triggers: [trigger("t-before", "beforeChange", "a-date")], + })); + const before = await readPage("orders"); + + // Order 3 is rejected, so the whole table stays as it was. + await expect( + runActionQuery("q-close", [{ column: "max", value: value("integer", 100) }]), + ).rejects.toThrow("Not saved: Orders over 25 stay open"); + expect((await readPage("orders")).rows).toEqual(before.rows); + + const run = await runActionQuery("q-close", [{ column: "max", value: value("integer", 25) }]); + expect([run.dryRun, run.changed]).toEqual([false, 2]); + const page = await readPage("orders"); + expect(page.rows.map((r) => [r[2], r[3]])).toEqual([ + [{ type: "date", value: today() }, text("closed")], + [{ type: "date", value: today() }, text("closed")], + [{ type: "date", value: "2024-01-31" }, text("open")], + ]); +}, 60_000); + +it("applies a before-change field to rows an insert action query creates", async () => { + const user = await renderNewDocument(); + await setup(); + await editConfig(user, () => ({ + savedQueries: [ + { + id: "q-copy", + name: "Copy orders", + sql: "INSERT INTO orders (id, total, status) SELECT id + 100, total, 'copy' FROM orders", + parameters: [], + action: { kind: "insert", table: "orders" }, + }, + ], + actions: [ + action("a-set", [{ kind: "setField", field: "status", value: "record.status & '!'" }]), + ], + triggers: [trigger("t-before", "beforeChange", "a-set")], + })); + const run = await runActionQuery("q-copy", []); + // Only created/updated/deleted triggers make Rust list the created rows. + expect(run.changed).toBe(3); + const page = await readPage("orders"); + expect((await column("orders", "status")).slice(3)).toEqual([ + text("copy!"), + text("copy!"), + text("copy!"), + ]); + expect(page.total).toBe(6); +}, 60_000); + +it("fires deleted triggers once per row of a delete action query", async () => { + const user = await renderNewDocument(); + await setup(); + await editConfig(user, () => ({ + savedQueries: [ + { + id: "q-purge", + name: "Purge small orders", + sql: "DELETE FROM orders WHERE total <= 20", + parameters: [], + action: { kind: "delete", table: "orders" }, + }, + ], + actions: [ + action("a-audit", [ + { + kind: "createRecord", + table: "audit", + values: { message: "'purged ' & record.id & ' total ' & old.total" }, + }, + ]), + ], + triggers: [trigger("t-del", "deleted", "a-audit")], + })); + const run = await runActionQuery("q-purge", []); + expect(run.deleted?.length).toBe(2); + expect((await readPage("orders")).total).toBe(1); + expect(await auditMessages()).toEqual(["purged 1 total 10", "purged 2 total 20"]); +}, 60_000); diff --git a/tests/integration/shell.test.tsx b/tests/integration/shell.test.tsx index 374d32e..c7a3f10 100644 --- a/tests/integration/shell.test.tsx +++ b/tests/integration/shell.test.tsx @@ -106,7 +106,7 @@ it("edits config YAML, reports YAML errors, and undoes an applied YAML change", expect(await screen.findByText("YAML applied.", {}, LONG)).toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(await screen.findByText("Inventory")).toBeInTheDocument(); - await waitFor(() => expect(editor).toHaveDisplayValue(/version: 4/)); + await waitFor(() => expect(editor).toHaveDisplayValue(/version: 5/)); await user.click(screen.getByRole("button", { name: "Undo" })); await waitFor(async () => expect((await readConfig()).name).toBe("Untitled"), LONG); diff --git a/tests/unit/before-change-triggers.test.ts b/tests/unit/before-change-triggers.test.ts new file mode 100644 index 0000000..599474f --- /dev/null +++ b/tests/unit/before-change-triggers.test.ts @@ -0,0 +1,403 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DocumentConfig } from "../../src/lib/types"; +import { createFakeBackend } from "./automation-fakes"; + +const state = vi.hoisted(() => ({ + backend: null as null | { invoke: (c: string, a?: Record) => Promise }, +})); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (command: string, args?: Record) => state.backend?.invoke(command, args), +})); + +const { installTriggers, BeforeChangeRejected } = await import("../../src/automation/triggers"); +const { insertRecord, updateRecord, deleteRecord, writeRecordBatch, runActionQuery } = await import( + "../../src/lib/records" +); +const { browserContext } = await import("../../src/automation/context"); +type Trigger = import("../../src/automation/types").Trigger; +type ActionDef = import("../../src/automation/types").ActionDef; +type Step = import("../../src/automation/types").Step; + +let db: ReturnType; +let config: DocumentConfig; +let uninstall: () => void; + +const text = (value: string) => ({ type: "text" as const, value }); +const int = (value: number) => ({ type: "integer" as const, value }); +const named = (column: string, value: string) => ({ column, value: text(value) }); +const action = (id: string, steps: Step[]): ActionDef => ({ id, name: id, onError: "stop", steps }); +const set = (id: string, field: string, value: string): Step => ({ + id, + kind: "setField", + field, + value, +}); +const trigger = (t: Partial): Trigger => ({ + id: "t1", + name: "Check orders", + table: "orders", + event: "beforeChange", + actionId: "stamp", + mode: "sync", + enabled: true, + maxAttempts: 1, + backoffMs: 0, + ...t, +}); +const auditAction = action("audit", [ + { + id: "a1", + kind: "createRecord", + table: "audit", + values: { message: "'deleted ' & record.id & ' ' & record.status & ' old=' & old.status" }, + }, +]); + +beforeEach(() => { + db = createFakeBackend(); + state.backend = db; + db.addTable("orders", ["id", "status", "note"], [{ id: 1, status: "open", note: null }]); + db.addTable("audit", ["id", "message"]); + config = { + actions: [action("stamp", [set("s1", "note", "'stamped ' & record.status")]), auditAction], + triggers: [], + savedQueries: [], + design: { forms: [] }, + } as unknown as DocumentConfig; + uninstall = installTriggers({ + getConfig: () => config, + context: (base) => browserContext(config, () => undefined, base), + }); +}); +afterEach(() => uninstall()); + +describe("beforeChange", () => { + it("sets a field on insert and the stored row has it", async () => { + config.triggers = [trigger({})]; + await insertRecord("orders", [named("status", "new")]); + expect(db.rows("orders")[1]).toMatchObject({ status: "new", note: "stamped new" }); + const sent = db.calls.find((c) => c.command === "insert_row" && c.args.table === "orders"); + expect(sent?.args.values).toContainEqual({ column: "note", value: text("stamped new") }); + }); + + it("sets a field on update from the old row merged with the new values", async () => { + config.actions = [ + action("stamp", [set("s1", "note", "old.status & '->' & record.status & ' #' & record.id")]), + ]; + config.triggers = [trigger({})]; + await updateRecord("orders", [named("status", "closed")], [int(1)]); + expect(db.rows("orders")[0]).toMatchObject({ status: "closed", note: "open->closed #1" }); + }); + + it("gives inserts a null old row", async () => { + config.actions = [action("stamp", [set("s1", "note", "coalesce(old.status, 'none')")])]; + config.triggers = [trigger({})]; + await insertRecord("orders", [named("status", "new")]); + expect(db.rows("orders")[1].note).toBe("none"); + }); + + it("rejects with the fail step's message and writes nothing", async () => { + config.actions = [ + action("stamp", [{ id: "f", kind: "fail", message: "'Status is required'", when: "true" }]), + ]; + config.triggers = [trigger({})]; + const error = await insertRecord("orders", [named("status", "x")]).catch((e) => e); + expect(error).toBeInstanceOf(BeforeChangeRejected); + expect(error.message).toBe("Not saved: Status is required"); + expect(db.rows("orders")).toHaveLength(1); + expect(db.calls.some((c) => c.command === "insert_row")).toBe(false); + }); + + it("rejects an update with a failing condition-gated fail step and leaves the row", async () => { + config.actions = [ + action("stamp", [ + { + id: "f", + kind: "fail", + message: "'Closed orders are final'", + when: "old.status = 'closed'", + }, + ]), + ]; + config.triggers = [trigger({})]; + db.rows("orders")[0].status = "closed"; + await expect(updateRecord("orders", [named("status", "open")], [int(1)])).rejects.toThrow( + "Not saved: Closed orders are final", + ); + expect(db.rows("orders")[0].status).toBe("closed"); + expect(db.calls.some((c) => c.command === "update_row")).toBe(false); + }); + + it("names the trigger when a step errors", async () => { + config.actions = [action("stamp", [set("s1", "note", "1 +")])]; + config.triggers = [trigger({})]; + await expect(insertRecord("orders", [named("status", "x")])).rejects.toThrow( + /^Not saved: trigger "Check orders" failed: /, + ); + expect(db.rows("orders")).toHaveLength(1); + }); + + it("refuses steps that write, with a message naming the step", async () => { + config.actions = [ + action("stamp", [ + { id: "c", kind: "createRecord", table: "audit", values: { message: "'x'" } }, + ]), + ]; + config.triggers = [trigger({})]; + await expect(insertRecord("orders", [named("status", "x")])).rejects.toThrow( + 'Not saved: A before-change trigger cannot run "Create record" steps; use an after trigger', + ); + expect(db.rows("orders")).toHaveLength(1); + expect(db.rows("audit")).toHaveLength(0); + }); + + it("skips a trigger whose condition is false", async () => { + config.triggers = [trigger({ condition: "record.status = 'open'" })]; + await insertRecord("orders", [named("status", "draft")]); + await insertRecord("orders", [named("status", "open")]); + expect(db.rows("orders").map((r) => r.note ?? null)).toEqual([null, null, "stamped open"]); + }); + + it("skips disabled triggers", async () => { + config.triggers = [trigger({ enabled: false })]; + await insertRecord("orders", [named("status", "open")]); + expect(db.rows("orders")[1].note ?? null).toBeNull(); + }); + + it("chains triggers in order, each seeing the fields earlier ones set", async () => { + config.actions = [ + action("first", [set("s1", "note", "'one'")]), + action("second", [set("s2", "note", "record.note & '+two'")]), + ]; + config.triggers = [ + trigger({ id: "t1", actionId: "first" }), + trigger({ id: "t2", actionId: "second" }), + ]; + await insertRecord("orders", [named("status", "x")]); + expect(db.rows("orders")[1].note).toBe("one+two"); + }); + + it("sees later steps' updated record within one action", async () => { + config.actions = [ + action("stamp", [set("s1", "note", "'a'"), set("s2", "status", "record.note & 'b'")]), + ]; + config.triggers = [trigger({})]; + await insertRecord("orders", [named("status", "x")]); + expect(db.rows("orders")[1]).toMatchObject({ note: "a", status: "ab" }); + }); + + it("stops the chain when an earlier trigger rejects", async () => { + config.actions = [ + action("first", [{ id: "f", kind: "fail", message: "'no'" }]), + action("second", [set("s2", "note", "'two'")]), + ]; + config.triggers = [ + trigger({ id: "t1", actionId: "first" }), + trigger({ id: "t2", actionId: "second" }), + ]; + await expect(insertRecord("orders", [named("status", "x")])).rejects.toThrow("Not saved: no"); + }); + + it("rejects a whole write batch and writes nothing", async () => { + config.actions = [ + action("stamp", [ + { id: "f", kind: "fail", message: "'bad row'", when: "record.status = 'bad'" }, + set("s1", "note", "'ok'"), + ]), + ]; + config.triggers = [trigger({})]; + const insert = (status: string) => ({ + operation: "insert" as const, + table: "orders", + values: [named("status", status)], + identity: null, + }); + await expect(writeRecordBatch([insert("good"), insert("bad")])).rejects.toThrow( + "Not saved: bad row", + ); + expect(db.rows("orders")).toHaveLength(1); + expect(db.calls.some((c) => c.command === "execute_write_batch")).toBe(false); + + await writeRecordBatch([insert("good"), insert("fine")]); + expect(db.rows("orders").map((r) => r.note)).toEqual([null, "ok", "ok"]); + }); +}); + +describe("deleted", () => { + it("runs sync triggers after the delete with the deleted row as record and old", async () => { + config.triggers = [trigger({ event: "deleted", actionId: "audit" })]; + await deleteRecord("orders", [int(1)]); + expect(db.rows("orders")).toHaveLength(0); + expect(db.rows("audit").map((r) => r.message)).toEqual(["deleted 1 open old=open"]); + }); + + it("uses meta.old when the caller passes it", async () => { + config.triggers = [trigger({ event: "deleted", actionId: "audit" })]; + await deleteRecord("orders", [int(1)], { old: { id: 1, status: "passed" } }); + expect(db.rows("audit").map((r) => r.message)).toEqual(["deleted 1 passed old=passed"]); + }); + + it("honors the trigger condition against the deleted row", async () => { + config.triggers = [ + trigger({ event: "deleted", actionId: "audit", condition: "old.status = 'x'" }), + ]; + await deleteRecord("orders", [int(1)]); + expect(db.rows("audit")).toHaveLength(0); + }); + + it("enqueues a job for async triggers with event deleted and the row", async () => { + config.triggers = [trigger({ event: "deleted", actionId: "audit", mode: "async" })]; + await deleteRecord("orders", [int(1)]); + expect(db.jobs).toHaveLength(1); + expect(db.jobs[0]).toMatchObject({ + triggerId: "t1", + actionId: "audit", + payload: { + table: "orders", + event: "deleted", + record: { id: 1, status: "open" }, + old: { id: 1, status: "open" }, + identity: [1], + }, + }); + expect(db.rows("audit")).toHaveLength(0); + }); +}); + +describe("action queries", () => { + const pendingRows = [1, 2].map((id) => ({ + identity: [int(id)], + values: [ + { column: "id", value: int(id) }, + { column: "status", value: text("paid") }, + ], + old: [ + { column: "id", value: int(id) }, + { column: "status", value: text("open") }, + ], + })); + const run = (extra: object = {}) => ({ + changed: 2, + removed: 0, + dryRun: false, + table: "orders", + created: [], + updated: [], + createdGrant: null, + updatedGrant: null, + ...extra, + }); + const queryCalls = () => db.calls.filter((c) => c.command === "run_action_query"); + + beforeEach(() => { + db.addTable( + "orders", + ["id", "status", "note"], + [ + { id: 1, status: "open", note: null }, + { id: 2, status: "open", note: null }, + ], + ); + db.on("release_trigger_grant", () => null); + }); + + it("runs beforeBulk hooks on pending rows and sends overrides with the fingerprint", async () => { + config.triggers = [trigger({})]; + db.on("run_action_query", (args) => + args.before + ? run({ + updated: pendingRows.map((r) => ({ identity: r.identity, old: r.old })), + }) + : run({ dryRun: true, pending: { fingerprint: "fp-1", rows: pendingRows } }), + ); + const result = await runActionQuery("pay", []); + expect(result.dryRun).toBe(false); + expect(queryCalls()).toHaveLength(2); + expect(queryCalls()[0].args.before).toBeUndefined(); + expect(queryCalls()[1].args.before).toEqual({ + fingerprint: "fp-1", + overrides: [ + { row: 0, values: [{ column: "note", value: text("stamped paid") }] }, + { row: 1, values: [{ column: "note", value: text("stamped paid") }] }, + ], + }); + }); + + it("gives each pending row's old values to the trigger", async () => { + config.actions = [action("stamp", [set("s1", "note", "old.status & '>' & record.status")])]; + config.triggers = [trigger({})]; + db.on("run_action_query", (args) => + args.before + ? run() + : run({ dryRun: true, pending: { fingerprint: "fp", rows: pendingRows } }), + ); + await runActionQuery("pay", []); + const overrides = (queryCalls()[1].args.before as { overrides: { values: unknown[] }[] }) + .overrides; + expect(overrides.map((o) => o.values)).toEqual([ + [{ column: "note", value: text("open>paid") }], + [{ column: "note", value: text("open>paid") }], + ]); + }); + + it("sends no overrides entry for rows the triggers leave alone", async () => { + config.triggers = [trigger({ condition: "record.id = 2" })]; + db.on("run_action_query", (args) => + args.before + ? run() + : run({ dryRun: true, pending: { fingerprint: "fp", rows: pendingRows } }), + ); + await runActionQuery("pay", []); + const before = queryCalls()[1].args.before as { overrides: { row: number }[] }; + expect(before.overrides.map((o) => o.row)).toEqual([1]); + }); + + it("makes no second call when a trigger rejects", async () => { + config.actions = [ + action("stamp", [{ id: "f", kind: "fail", message: "'No paying'", when: "record.id = 2" }]), + ]; + config.triggers = [trigger({})]; + db.on("run_action_query", () => + run({ dryRun: true, pending: { fingerprint: "fp", rows: pendingRows } }), + ); + await expect(runActionQuery("pay", [])).rejects.toThrow("Not saved: No paying"); + expect(queryCalls()).toHaveLength(1); + }); + + it("does not run before-change hooks on a dry run", async () => { + config.triggers = [trigger({})]; + db.on("run_action_query", (args) => run({ dryRun: !!args.dryRun })); + const result = await runActionQuery("pay", [], { dryRun: true }); + expect(result.dryRun).toBe(true); + expect(queryCalls()).toHaveLength(1); + }); + + it("fires deleted triggers once per deleted row and releases the grant once", async () => { + config.triggers = [trigger({ event: "deleted", actionId: "audit" })]; + db.on("run_action_query", () => + run({ + removed: 2, + changed: 0, + deleted: pendingRows.map((r) => ({ identity: r.identity, values: r.old })), + deletedGrant: "grant-d", + }), + ); + await runActionQuery("purge", []); + expect(db.rows("audit").map((r) => r.message)).toEqual([ + "deleted 1 open old=open", + "deleted 2 open old=open", + ]); + const released = db.calls.filter((c) => c.command === "release_trigger_grant"); + expect(released.map((c) => c.args.grant)).toEqual(["grant-d"]); + }); + + it("enqueues one async deleted job per deleted row", async () => { + config.triggers = [trigger({ event: "deleted", actionId: "audit", mode: "async" })]; + db.on("run_action_query", () => + run({ deleted: pendingRows.map((r) => ({ identity: r.identity, values: r.old })) }), + ); + await runActionQuery("purge", []); + expect(db.jobs.map((j) => (j.payload as { identity: unknown[] }).identity)).toEqual([[1], [2]]); + expect(db.jobs.every((j) => (j.payload as { event: string }).event === "deleted")).toBe(true); + }); +});