Skip to content
Closed
42 changes: 41 additions & 1 deletion .agents/skills/constructive-data-modeling/SKILL.md
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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`)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
161 changes: 161 additions & 0 deletions .agents/skills/constructive-data-modeling/references/triggers.md
Original file line number Diff line number Diff line change
@@ -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 <app_private>.<name>() RETURNS TRIGGER ...` |
| Trigger | `db.trigger.create` | `CREATE TRIGGER <name> <timing> <events> ON <table> ...` |

## 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 <name>` — the OLD transition relation |
| `transitionNewName` | string | — | `REFERENCING NEW TABLE AS <name>` — 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.
1 change: 1 addition & 0 deletions .agents/skills/constructive-features/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down