diff --git a/.agents/skills/constructive-data-modeling/SKILL.md b/.agents/skills/constructive-data-modeling/SKILL.md index c0213e8..827e0b6 100644 --- a/.agents/skills/constructive-data-modeling/SKILL.md +++ b/.agents/skills/constructive-data-modeling/SKILL.md @@ -1,6 +1,6 @@ --- name: constructive-data-modeling -description: "Tables, fields, relations, constraints, indexes, enums, views, and database provisioning via the type-safe SDK. Use when asked to 'create a table', 'add a field', 'add a column', 'create a relation', 'add a constraint', 'add an index', 'create a foreign key', 'add a primary key', 'add a unique constraint', 'define field types', 'provision a database', 'create an enum', 'create a view', 'security_invoker', 'security_barrier', 'WITH CHECK OPTION', 'check_option', 'api_required', 'temporal table', 'application-time temporal', 'WITHOUT OVERLAPS', 'temporal foreign key', 'WITH PERIOD', 'period column', 'ON DELETE SET NULL (col)', 'ON DELETE SET DEFAULT (col)', 'FK column-list referential action', 'delete_set_field_ids', 'deferrable constraint', 'DEFERRABLE', 'INITIALLY DEFERRED', 'deferred constraint', 'isDeferrable', 'initiallyDeferred', 'exclusion constraint', 'EXCLUDE USING', 'EXCLUDE constraint', 'no overlap', 'no_overlap', 'gist exclusion', 'exclusionConstraint', 'identity column', 'GENERATED ALWAYS AS IDENTITY', 'GENERATED BY DEFAULT AS IDENTITY', 'auto-increment column', 'identityGeneration', 'sequence options', or when working with metaschema_public operations." +description: "Tables, fields, relations, constraints, indexes, enums, triggers, views, and database provisioning via the type-safe SDK. Use when asked to 'create a table', 'add a field', 'add a column', 'create a relation', 'add a constraint', 'add an index', 'create a foreign key', 'add a primary key', 'add a unique constraint', 'define field types', 'provision a database', 'create an enum', 'create a view', 'security_invoker', 'security_barrier', 'WITH CHECK OPTION', 'check_option', 'api_required', 'temporal table', 'application-time temporal', 'WITHOUT OVERLAPS', 'temporal foreign key', 'WITH PERIOD', 'period column', 'ON DELETE SET NULL (col)', 'ON DELETE SET DEFAULT (col)', 'FK column-list referential action', 'delete_set_field_ids', 'deferrable constraint', 'DEFERRABLE', 'INITIALLY DEFERRED', 'deferred constraint', 'isDeferrable', 'initiallyDeferred', 'exclusion constraint', 'EXCLUDE USING', 'EXCLUDE constraint', 'no overlap', 'no_overlap', 'gist exclusion', 'exclusionConstraint', 'identity column', 'GENERATED ALWAYS AS IDENTITY', 'GENERATED BY DEFAULT AS IDENTITY', 'auto-increment column', 'identityGeneration', 'sequence options', 'create a trigger', 'add a trigger', 'trigger function', 'transition tables', 'REFERENCING OLD/NEW TABLE', 'statement-level trigger', 'FOR EACH STATEMENT', 'trigger WHEN clause', or when working with metaschema_public operations." metadata: author: constructive-io version: "1.0.0" @@ -21,6 +21,7 @@ Use this skill when: - Adding identity columns (`GENERATED ALWAYS / BY DEFAULT AS IDENTITY`) with sequence options - Setting `api_required` on nullable FK columns - Adding primary key / unique / foreign key constraints +- Creating custom triggers + trigger functions, including transition tables (`REFERENCING OLD/NEW TABLE AS`), statement-level triggers, and conditional `WHEN` clauses - Declaring application-time temporal constraints (PG18): `WITHOUT OVERLAPS` keys and temporal (`WITH PERIOD`) foreign keys - Declaring FK column-list referential actions (PG18): `ON DELETE SET NULL (col)` / `SET DEFAULT (col)` on a subset of the FK columns - Declaring deferrable constraints: `DEFERRABLE` / `INITIALLY DEFERRED` on primary key / unique / foreign key constraints (`isDeferrable` / `initiallyDeferred`) @@ -337,6 +338,44 @@ Both default to `null`, so existing simple/advanced indexes are unchanged. See [indexes.md](./references/indexes.md) for the full matrix, the expression-AST escape hatch, and combining predicates with expressions. +## Triggers + +Custom triggers are declared via `db.trigger.create`, referencing a trigger +function created with `db.triggerFunction.create`. Create the function first, +then the trigger (a trigger row without a `functionName` is registration-only and +is not provisioned into a physical trigger): + +```typescript +await db.triggerFunction.create({ + data: { databaseId, name: 'audit_fn', code: 'BEGIN RETURN NULL; END' }, + select: { id: true }, +}).execute(); + +await db.trigger.create({ + data: { + databaseId, + tableId, + name: 'audit', + functionName: 'audit_fn', + timing: 'after', // before | after | instead_of + events: ['update'], // insert | update | delete | truncate + forEach: 'statement', // row | statement + transitionOldName: 'o', // REFERENCING OLD TABLE AS o + transitionNewName: 'n', // REFERENCING NEW TABLE AS n + whenClause: { field: 'status', op: 'IS DISTINCT FROM', row: 'OLD' }, + }, + select: { id: true }, +}).execute(); +// → CREATE TRIGGER audit AFTER UPDATE ON t +// REFERENCING OLD TABLE AS o NEW TABLE AS n +// FOR EACH STATEMENT EXECUTE FUNCTION audit_fn(); +``` + +`whenClause` uses the same condition DSL as `JobTrigger`/`EventTracker`. For the +common declarative behaviors, prefer the Node Type generators (`JobTrigger`, +`EventTracker`, `DataSlug`, etc.) over hand-authored triggers. See +[triggers.md](./references/triggers.md) for the full reference. + ## Views Create views via `db.view.create`; `viewType` selects the view body (`View*` node @@ -391,6 +430,7 @@ await db.field.update({ | [field-types.md](./references/field-types.md) | Complete field type reference | | [identity-columns.md](./references/identity-columns.md) | Identity columns — `GENERATED ALWAYS / BY DEFAULT AS IDENTITY` + sequence options (`identityGeneration` / `identityOptions`) | | [provisioning.md](./references/provisioning.md) | Full database provisioning flow | +| [triggers.md](./references/triggers.md) | Custom triggers + trigger functions: timing, events, `forEach`, transition tables, `whenClause` DSL | | [views.md](./references/views.md) | View creation + options (`securityInvoker`, `securityBarrier`, `WITH [LOCAL\|CASCADED] CHECK OPTION`) | ## Cross-References diff --git a/.agents/skills/constructive-data-modeling/references/triggers.md b/.agents/skills/constructive-data-modeling/references/triggers.md new file mode 100644 index 0000000..aecbcb3 --- /dev/null +++ b/.agents/skills/constructive-data-modeling/references/triggers.md @@ -0,0 +1,161 @@ +# Triggers + +Custom database triggers are declared through the SDK ORM against the metaschema +`trigger` catalog. A trigger row references a **trigger function** (created via +`db.triggerFunction.create`) and a target table; on insert it compiles to a +PostgreSQL `CREATE TRIGGER ...` that runs the function. + +| Entity | ORM entity | Generates | +|--------|-----------|-----------| +| Trigger function | `db.triggerFunction.create` | `CREATE FUNCTION .() RETURNS TRIGGER ...` | +| Trigger | `db.trigger.create` | `CREATE TRIGGER ON ...` | + +## Two-step flow + +A declarative trigger is provisioned only when its `functionName` resolves to an +existing trigger function. So always create the function first, then the trigger: + +```typescript +// 1. Trigger function — lives in the database's private (app_private) schema. +await db.triggerFunction.create({ + data: { + databaseId, + name: 'audit_fn', + code: 'BEGIN INSERT INTO app_private.audit_log(t, at) VALUES (TG_OP, now()); RETURN NULL; END', + }, + select: { id: true, name: true }, +}).execute(); + +// 2. Declarative trigger referencing that function by name. +await db.trigger.create({ + data: { + databaseId, + tableId, // target table (metaschema table id) + name: 'audit', + functionName: 'audit_fn', // resolved in the private schema + timing: 'after', // before | after | instead_of + events: ['update'], // any of insert | update | delete | truncate + forEach: 'statement', // row | statement + transitionOldName: 'o', // REFERENCING OLD TABLE AS o + transitionNewName: 'n', // REFERENCING NEW TABLE AS n + }, + select: { id: true }, +}).execute(); +``` + +This generates: + +```sql +CREATE TRIGGER audit AFTER UPDATE ON t + REFERENCING OLD TABLE AS o NEW TABLE AS n + FOR EACH STATEMENT EXECUTE FUNCTION audit_fn(); +``` + +## Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `tableId` | UUID | — | Target table (required) | +| `name` | string | — | Trigger name (required) | +| `functionName` | string | — | Trigger function name, resolved in the database's private schema. **Omit to register a catalog row without provisioning a physical trigger** | +| `timing` | string | `after` | `before`, `after`, or `instead_of` | +| `events` | string[] | — | One or more of `insert`, `update`, `delete`, `truncate` | +| `forEach` | string | `row` | `row` (FOR EACH ROW) or `statement` (FOR EACH STATEMENT) | +| `transitionOldName` | string | — | `REFERENCING OLD TABLE AS ` — the OLD transition relation | +| `transitionNewName` | string | — | `REFERENCING NEW TABLE AS ` — the NEW transition relation | +| `whenClause` | JSON | — | Optional `WHEN (...)` predicate, expressed with the condition DSL (below) | + +All of `timing` / `forEach` / `events` / `transition*` / `whenClause` are optional +and nullable. A row created with only `tableId` + `name` (no `functionName`) is a +registration-only entry — it is **not** compiled into a physical trigger, so +existing catalog rows and generators are unaffected. + +## Transition tables (statement-level, PostgreSQL) + +`transitionOldName` / `transitionNewName` expose `REFERENCING OLD/NEW TABLE AS`, +which give the trigger function set-oriented `OLD TABLE` / `NEW TABLE` relations +covering every row touched by the statement. They pair naturally with +`forEach: 'statement'` for efficient audit / sync triggers that process the whole +change set once per statement instead of once per row. + +```typescript +await db.trigger.create({ + data: { + databaseId, tableId, + name: 'audit', + functionName: 'audit_fn', + timing: 'after', + events: ['update'], + forEach: 'statement', + transitionNewName: 'n', // only NEW TABLE — OLD TABLE omitted + }, + select: { id: true }, +}).execute(); +// → CREATE TRIGGER audit AFTER UPDATE ON t +// REFERENCING NEW TABLE AS n +// FOR EACH STATEMENT EXECUTE FUNCTION audit_fn(); +``` + +Either transition name may be given independently; provide both for +`OLD TABLE AS ... NEW TABLE AS ...`. + +## `whenClause` (conditional firing) + +`whenClause` accepts the same structured condition DSL used by `JobTrigger` +`conditions` and `EventTracker` — it is compiled to the trigger's `WHEN (...)` +clause and validated against the table's columns at provisioning time. No raw SQL. + +**Leaf condition:** +```typescript +{ field: 'status', op: 'IS DISTINCT FROM', row: 'OLD' } +``` + +| Key | Required | Default | Description | +|-----|----------|---------|-------------| +| `field` | yes | — | Column name (validated against the table) | +| `op` | yes | — | `=`, `!=`, `>`, `<`, `>=`, `<=`, `LIKE`, `NOT LIKE`, `IS NULL`, `IS NOT NULL`, `IS DISTINCT FROM` | +| `value` | conditional | — | Comparison value (omit for `IS NULL`, `IS NOT NULL`, `IS DISTINCT FROM`) | +| `row` | no | `NEW` | Row reference: `NEW` or `OLD` | +| `ref` | no | — | Field-to-field comparison: `{ field, row }` | + +Arrays are an implicit AND; `{ AND: [...] }`, `{ OR: [...] }`, and `{ NOT: {...} }` +combinators nest arbitrarily. + +```typescript +await db.trigger.create({ + data: { + databaseId, tableId, + name: 'audit_status_change', + functionName: 'audit_fn', + timing: 'after', + events: ['update'], + forEach: 'row', + whenClause: { field: 'status', op: 'IS DISTINCT FROM', row: 'OLD' }, + }, + select: { id: true }, +}).execute(); +// → CREATE TRIGGER ... FOR EACH ROW +// WHEN (NEW.status IS DISTINCT FROM OLD.status) +// EXECUTE FUNCTION audit_fn(); +``` + +> `IS DISTINCT FROM` always compares the `NEW` and `OLD` value of `field` +> regardless of the `row` given, matching PostgreSQL's change-detection idiom. + +See the shared condition grammar in +[`constructive-jobs`](../../constructive-jobs/SKILL.md) and +[`constructive-events`](../../constructive-events/SKILL.md). + +## Notes & gaps + +- Trigger functions are created in the database's **private** (`app_private`) + schema; `functionName` is resolved there. +- For the common declarative behaviors (timestamps, slugs, soft-delete, job + enqueue on row change, event tracking) prefer the corresponding Node Type + generators rather than authoring a raw trigger — see + [`constructive-jobs`](../../constructive-jobs/SKILL.md) (`JobTrigger`) and + [`constructive-events`](../../constructive-events/SKILL.md) (`EventTracker`). + Use `db.trigger.create` for custom logic those generators don't cover. +- Constraint triggers (`CREATE CONSTRAINT TRIGGER`, `DEFERRABLE`) and per-trigger + `WHEN` referencing other tables have no SDK surface yet — treat these as SDK + gaps rather than dropping to raw SQL. diff --git a/.agents/skills/constructive-features/SKILL.md b/.agents/skills/constructive-features/SKILL.md index d352452..efbd596 100644 --- a/.agents/skills/constructive-features/SKILL.md +++ b/.agents/skills/constructive-features/SKILL.md @@ -110,6 +110,7 @@ Want to assemble these features into a working app rather than look one up? Use | Field protection (DataOwnedFields, DataImmutableFields) | Node Type Registry | — | [`constructive-platform`](../constructive-blueprints/references/blueprint-definition-format.md) | | DataInheritFromParent (copy values from FK parent) | Node Type Registry | — | [`constructive-platform`](../constructive-blueprints/references/blueprint-definition-format.md) | | DataI18n (translation tables + multilingual search) | `DataI18n` node + `i18n_module` | — | [`constructive-i18n`](../constructive-i18n/SKILL.md) | +| Custom triggers (transition tables, statement-level, `WHEN`) | `db.trigger.create` + `db.triggerFunction.create` | — | [`constructive-data-modeling`](../constructive-data-modeling/references/triggers.md) | | Smart tags (GraphQL schema hints) | field-level | — | [`constructive-codegen`](../constructive-codegen/SKILL.md) | ## 5. Events & Achievements diff --git a/features.md b/features.md index 123539a..d0e6719 100644 --- a/features.md +++ b/features.md @@ -235,6 +235,10 @@ Native PostgreSQL identity columns — `GENERATED ALWAYS AS IDENTITY` and `GENER B-tree, GIN, GiST, BRIN, and hash access methods. Partial indexes, unique indexes, and expression indexes supported. +### Custom Triggers + +For logic beyond the built-in node types, custom triggers are authorable directly through the SDK ORM (`db.triggerFunction.create` + `db.trigger.create`) — no raw SQL. Supports `before` / `after` / `instead_of` timing, any combination of `insert` / `update` / `delete` / `truncate` events, row- vs statement-level firing (`FOR EACH ROW` / `FOR EACH STATEMENT`), transition tables (`REFERENCING OLD/NEW TABLE AS`), and conditional `WHEN` clauses via the shared condition DSL. + ### Behavior Triggers (Node Types) 84 declarative node types across 12 categories — add behavior to any table without writing SQL: