create-restforge-skills 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  # Reference: RDF Advanced Features
2
2
 
3
3
  > **Offline mirror.** This file mirrors the RDF catalog of the installed
4
- > RESTForge platform (`restforge-handbook/catalogs/rdf/`). The live platform is
5
- > authoritative — when this file and the platform disagree, trust the platform,
6
- > then update this file. For `fieldValidation` constraints, see
7
- > `references/field-validation.md`.
4
+ > RESTForge platform (`restforge-handbook/catalogs/rdf/`) and the key shapes the
5
+ > runtime actually reads. The live platform is authoritative — when this file
6
+ > and the platform disagree, trust the platform, then update this file. For
7
+ > `fieldValidation` constraints, see `references/field-validation.md`.
8
8
 
9
9
  This reference covers advanced RDF payload features beyond standard CRUD fields.
10
10
 
@@ -13,7 +13,7 @@ explains the shapes, the tools return what the *installed* platform accepts:
13
13
 
14
14
  | Editing | Call first |
15
15
  |---|---|
16
- | `fieldValidation` on master or detail fields (all sections below that show a `fields[]` entry) | `codegen_get_field_validation_catalog` |
16
+ | `fieldValidation` on master or detail columns | `codegen_get_field_validation_catalog` |
17
17
  | `datatablesQuery`, `viewQuery`, `viewName`, `exportQuery`, `detailQuery`, and `file:` query references | `codegen_get_query_declarative_catalog` |
18
18
  | Any SELECT / WITH statement before it is pasted into the payload or a `.sql` file | `codegen_validate_sql` (live EXPLAIN, executes no rows) |
19
19
 
@@ -21,22 +21,58 @@ Whatever the section, the finished payload goes through `codegen_validate_payloa
21
21
  before `codegen_create_endpoint` — a processor payload through
22
22
  `codegen_create_processor`.
23
23
 
24
+ **Start from a generated payload.** `codegen_generate_payload` writes `tableName`,
25
+ `primaryKey`, `fieldName`, `action`, `fieldValidation`, `uniqueConstraints`,
26
+ `dateTimeFields`, `deleteReferences`, `softDelete`, and (for a table with an
27
+ `is_active` column) `defaultScope`. Edit that file; do not write those keys by
28
+ hand. Later `codegen_generate_payload` / `codegen_sync_payload` runs keep the
29
+ customisations made to those keys (see SKILL.md § RDF Payload).
30
+
24
31
  ---
25
32
 
26
33
  ## Table of Contents
27
34
 
28
- 1. [Data Source Resolution](#data-source-resolution)
29
- 2. [Query File Reference](#query-file-reference)
30
- 3. [Field Lookup](#field-lookup)
31
- 4. [Default Scope](#default-scope)
32
- 5. [Workflow (Change-Status)](#workflow-change-status)
33
- 6. [Master-Detail (Composite)](#master-detail-composite)
34
- 7. [Aggregate Config](#aggregate-config)
35
- 8. [Adjust Config](#adjust-config)
36
- 9. [Import Config](#import-config)
37
- 10. [Processor](#processor)
38
- 11. [Kafka Event Publishing](#kafka-event-publishing)
39
- 12. [Components (Lifecycle Hooks)](#components-lifecycle-hooks)
35
+ 1. [The `action` Block](#the-action-block)
36
+ 2. [Data Source Resolution](#data-source-resolution)
37
+ 3. [Query File Reference](#query-file-reference)
38
+ 4. [Field Lookup](#field-lookup)
39
+ 5. [Default Scope](#default-scope)
40
+ 6. [Workflow (Change-Status)](#workflow-change-status)
41
+ 7. [Master-Detail (Composite)](#master-detail-composite)
42
+ 8. [Aggregate Config](#aggregate-config)
43
+ 9. [Adjust Config](#adjust-config)
44
+ 10. [Import Config](#import-config)
45
+ 11. [Processor](#processor)
46
+ 12. [Kafka Event Publishing](#kafka-event-publishing)
47
+ 13. [Components (Lifecycle Hooks)](#components-lifecycle-hooks)
48
+ 14. [Other RDF Blocks](#other-rdf-blocks)
49
+
50
+ ---
51
+
52
+ ## The `action` Block
53
+
54
+ `action` is required. Every key is a boolean flag; an unknown key produces a
55
+ warning, a non-boolean value is an error. A feature block alone does not create
56
+ its endpoint — the matching flag must be `true`.
57
+
58
+ | Flag | Endpoint(s) | Also needs |
59
+ |---|---|---|
60
+ | `datatables` | `POST /datatables` | — |
61
+ | `create`, `update`, `delete` | `POST /create`, `/update`, `/delete` | — |
62
+ | `first`, `read`, `lookup` | `POST /first`, `/read`, `GET`/`POST /lookup` | — |
63
+ | `export` | `/export` (Excel) | — |
64
+ | `import` | `/import-upload`, `/import-preview`, `/import-commit`, `/import-status` | `importConfig` with `enabled: true` |
65
+ | `upload` | file upload routes | `uploadConfig` |
66
+ | `adjust` | `POST /adjust` | `adjustConfig` |
67
+ | `aggregate` | `POST /aggregate` | `aggregateConfig` only when JOINs are needed |
68
+ | `workflow` | `POST /change-status` | `workflow` block |
69
+ | `createComposite`, `updateComposite`, `readComposite` | `/create-composite`, `/update-composite`, `/read-composite` | `masterDetail` block |
70
+ | `restore` | `/restore` | `softDelete.enabled: true` (schema-derived) |
71
+
72
+ `/export` is registered for every CRUD module whether or not the flag is set;
73
+ the flag only shows up in `GET /info`. `/import-*` is registered only when
74
+ `importConfig.enabled` is `true`; set `action.import: true` as well so the RDF
75
+ and `/info` describe the endpoints that actually exist.
40
76
 
41
77
  ---
42
78
 
@@ -47,29 +83,45 @@ only what is needed; the platform falls back automatically.
47
83
 
48
84
  | Endpoint | Resolution order |
49
85
  |---|---|
50
- | `/datatables` | `datatablesQuery` → `SELECT * FROM tableName` |
86
+ | `/datatables` | `datatablesQuery` → `SELECT * FROM` (`viewName` or `tableName`) |
51
87
  | `/read`, `/first`, `/lookup` | `viewName` → `viewQuery` → `tableName` |
52
- | `/export` | `exportQuery` → `SELECT {fields} FROM tableName` |
53
- | `/read-composite` (detail) | `detailQuery` → detail `tableName` |
88
+ | `/export` | `exportQuery` → `SELECT {fieldName} FROM {tableName}` |
89
+ | `/read-composite` (detail) | `masterDetail.detailConfig.detailQuery` → `SELECT * FROM {detailTable} WHERE {foreignKey} = ? ORDER BY {line_number or detail primaryKey}` |
54
90
 
55
91
  **`viewName`** — reference a database VIEW:
56
92
  ```json
57
93
  "viewName": "v_order_summary"
58
94
  ```
59
95
 
60
- **`viewQuery`** — inline SQL (virtual view, no DB object created):
96
+ **`viewQuery`** — inline SQL (virtual view, no DB object created). It is wrapped
97
+ as a subquery, so JOINed columns can be filtered in WHERE:
61
98
  ```json
62
99
  "viewQuery": "SELECT o.*, c.customer_name FROM orders o JOIN customers c ON o.customer_id = c.customer_id"
63
100
  ```
64
101
 
65
- **`datatablesQuery`** — SQL for the paginated table with `:search`, `:sort`,
66
- `:limit`, `:offset` placeholders:
102
+ **`datatablesQuery`** — the base SELECT for the paginated list. The runtime wraps
103
+ it with search, sort, and paging; do not add those clauses yourself:
104
+ ```json
105
+ "datatablesQuery": "SELECT o.*, c.customer_name FROM orders o JOIN customers c ON o.customer_id = c.customer_id"
106
+ ```
107
+
108
+ **`datatablesWhere`** — the whitelist of columns the `/datatables` `searchBy`
109
+ parameter may target; add `"all"` to allow cross-column search. Entries use the
110
+ column name as it appears in the SELECT result, never with a table alias
111
+ (`a.supplier_code` is rejected). Every entry other than `all` must exist in the
112
+ columns `/datatables` returns; `codegen_create_endpoint` checks this. A
113
+ `searchBy` value outside the list is rejected with HTTP 400.
67
114
  ```json
68
- "datatablesQuery": "SELECT o.*, c.customer_name FROM orders o JOIN customers c ON o.customer_id = c.customer_id WHERE 1=1"
115
+ "datatablesWhere": ["supplier_code", "supplier_name", "all"]
69
116
  ```
70
117
 
71
118
  Write source is always `tableName` — `viewName`/`viewQuery` are read-only.
72
119
 
120
+ **JOIN columns in the list.** To show columns of referenced tables
121
+ (`supplier_name` next to `supplier_id`), prefer `codegen_sync_payload` with
122
+ `expandFk` over a hand-written JOIN: it writes `query/<table>-join.sql` and
123
+ points `datatablesQuery` (and, with `expandFk: "both"`, `viewQuery`) at it.
124
+
73
125
  Ground every one of these keys with `codegen_get_query_declarative_catalog`
74
126
  before writing them, and run the SQL through `codegen_validate_sql` first: a
75
127
  JOIN or column typo here surfaces as a runtime 500 on `/datatables` or `/export`,
@@ -80,224 +132,292 @@ not as a payload validation error.
80
132
  ## Query File Reference
81
133
 
82
134
  SQL queries can be stored in external `.sql` files using the `file:` prefix.
83
- Path is relative to the payload file location.
135
+ The path is relative to the `payload/` folder.
84
136
 
85
137
  ```json
86
- "datatablesQuery": "file:sql/orders-datatables.sql",
87
- "exportQuery": "file:sql/orders-export.sql"
138
+ "datatablesQuery": "file:query/orders-datatables.sql",
139
+ "exportQuery": "file:query/orders-export.sql"
88
140
  ```
89
141
 
90
142
  Convention for folder structure:
91
143
  ```
92
144
  payload/
93
145
  ├── order.json
94
- └── sql/
146
+ └── query/
95
147
  ├── orders-datatables.sql
96
148
  └── orders-export.sql
97
149
  ```
98
150
 
99
- External SQL files support the same placeholders as inline queries.
100
- For master-detail, each detail query is in a separate file.
151
+ `viewName` does not accept `file:` because it names a database object. For
152
+ master-detail, `detailQuery` lives under `masterDetail.detailConfig` and may use
153
+ `file:` too.
101
154
 
102
155
  The `file:` form hides the SQL from a quick payload review, which makes
103
156
  `codegen_validate_sql` on the file content worth more here than for inline
104
- queries. The placeholder rules themselves come from
105
- `codegen_get_query_declarative_catalog`.
157
+ queries.
106
158
 
107
159
  ---
108
160
 
109
161
  ## Field Lookup
110
162
 
111
- Configures dropdown/autocomplete data for a field. Used for foreign key fields
112
- that need a human-readable label.
163
+ `fieldNameLookup` (root level, optional) selects which columns `/lookup` returns
164
+ as `id` and `text`. There is no per-field lookup key in RDF; a dropdown on the
165
+ frontend is a UDF `dataSource` that calls this endpoint.
113
166
 
114
167
  ```json
115
- {
116
- "fieldName": "category_id",
117
- "type": "string",
118
- "fieldLookup": {
119
- "apiPath": "/category",
120
- "id": "category_id",
121
- "text": "category_name"
122
- }
168
+ "fieldNameLookup": {
169
+ "id": "category_id",
170
+ "text": "category_code||' - '||category_name as display_text"
123
171
  }
124
172
  ```
125
173
 
126
- - `apiPath` — backend resource that provides lookup options via `/lookup` endpoint.
127
- - `id` — field returned as the stored value.
128
- - `text` — field returned as the display label.
129
-
130
- `fieldLookup` sits on a field that also carries `fieldValidation`; ground those
131
- constraints with `codegen_get_field_validation_catalog` rather than reusing the
132
- keys shown in the examples here.
174
+ | Key | Notes |
175
+ |---|---|
176
+ | `id` | Column returned as `id` |
177
+ | `text` | Column **or SQL expression** returned as `text`. PostgreSQL `||` concatenation is translated to `CONCAT()` for MySQL automatically |
133
178
 
134
- Static lookup (no API call):
135
- ```json
136
- "fieldLookup": {
137
- "type": "static",
138
- "options": [
139
- { "id": "A", "text": "Option A" },
140
- { "id": "B", "text": "Option B" }
141
- ]
142
- }
143
- ```
179
+ - Both keys are required once the block exists.
180
+ - Without the block, the text column is auto-detected by name (`name`, `code`,
181
+ `title`, `text`, `label`, `tag`).
182
+ - With the block, `/lookup` search runs on the columns extracted from `text`.
144
183
 
145
184
  ---
146
185
 
147
186
  ## Default Scope
148
187
 
149
- Automatic WHERE clause injected on `/lookup` and `/read`-family endpoints.
150
- Used for tenant isolation, user-scoped data, or active record filtering.
188
+ Automatic WHERE filter injected on the `lookup` and `read` actions only.
189
+ `/datatables` and `/first` are not affected.
151
190
 
152
191
  ```json
153
192
  "defaultScope": {
154
- "actions": ["lookup", "read", "datatables"],
155
- "conditions": [
156
- { "key": "is_active", "value": true },
157
- { "key": "company_id", "value": ":companyId" }
158
- ]
193
+ "lookup": { "is_active": true },
194
+ "read": { "is_active": true }
159
195
  }
160
196
  ```
161
197
 
162
- - `actions[]` — which endpoints apply the scope.
163
- - Conditions with `:paramName` resolve from the request context (e.g., JWT claims).
164
- - Combines with user-supplied WHERE via AND.
165
- - The `is_active` column is auto-synced by the processor's `.active()` method.
198
+ - The keys are action names: only `lookup` (`GET` and `POST /lookup`) and `read`
199
+ (`POST /read`). Any other key produces a warning and is ignored.
200
+ - Each action maps `column: value`. The column must be in `fieldName`; the value
201
+ must be a boolean, string, or number literal. Values are not taken from the
202
+ request or the JWT.
203
+ - The filter is combined with the user-supplied WHERE via AND.
204
+ - For an `is_active` column, `codegen_generate_payload` writes the block
205
+ automatically and `codegen_sync_payload` keeps only the `is_active` key in
206
+ step with the table; custom keys (e.g. `show_in_store`) are left alone.
166
207
 
167
208
  ---
168
209
 
169
210
  ## Workflow (Change-Status)
170
211
 
171
- Adds a `/change-status` endpoint with state machine validation.
212
+ Adds a `POST /change-status` endpoint with state machine validation. Needs both
213
+ `action.workflow: true` and the `workflow` block.
172
214
 
173
215
  ```json
216
+ "action": { "workflow": true },
174
217
  "workflow": {
175
218
  "statusField": "status",
176
- "transitions": [
177
- {
178
- "from": "draft",
179
- "to": "submitted",
180
- "action": "submit",
181
- "onBefore": "http://internal-service/validate",
182
- "onAfter": "http://notification-service/notify"
183
- },
184
- {
185
- "from": "submitted",
186
- "to": "approved",
187
- "action": "approve"
188
- },
189
- {
190
- "from": ["submitted", "approved"],
191
- "to": "rejected",
192
- "action": "reject"
219
+ "transitions": {
220
+ "draft": ["confirmed", "cancelled"],
221
+ "confirmed": ["closed", "cancelled"],
222
+ "closed": [],
223
+ "cancelled": []
224
+ },
225
+ "hooks": {
226
+ "confirmed": {
227
+ "onBefore": [],
228
+ "onAfter": [
229
+ {
230
+ "type": "api",
231
+ "method": "POST",
232
+ "url": "/stock-management/process-inbound",
233
+ "body": { "stock_inbound_id": "{{id}}", "status": "{{newStatus}}", "previous_status": "{{oldStatus}}" },
234
+ "blocking": true,
235
+ "timeout": 10000
236
+ }
237
+ ]
193
238
  }
194
- ]
239
+ }
195
240
  }
196
241
  ```
197
242
 
198
243
  | Property | Notes |
199
244
  |---|---|
200
- | `statusField` | Field name that holds the current status |
201
- | `transitions[].from` | Current status (string or array of strings) |
202
- | `transitions[].to` | Target status after transition |
203
- | `transitions[].action` | Action identifier in the request body |
204
- | `transitions[].onBefore` | HTTP call before transition; 4xx/5xx blocks the transition (HTTP 422/502) |
205
- | `transitions[].onAfter` | HTTP call after transition; failure does not roll back |
245
+ | `statusField` | Column that holds the status (default `"status"`); must be in `fieldName` |
246
+ | `transitions` | Map `current status → [allowed target statuses]`. `null`/absent = any transition allowed. A target outside the list → HTTP 422 `Status transition not allowed` |
247
+ | `hooks.<target status>` | Keyed by the **target** status, not by an action name. Holds `onBefore[]` and/or `onAfter[]` |
248
+ | hook `type` | Only `"api"` |
249
+ | hook `method` | `POST` (default), `PUT`, or `PATCH` |
250
+ | hook `url` | Full URL, absolute path (`/api/...`), or short path (`/{endpoint}/{action}`) |
251
+ | hook `body` / `headers` | Template variables `{{id}}`, `{{newStatus}}`, `{{oldStatus}}`, `{{record.<field>}}` |
252
+ | hook `blocking` | `true` = a failed call rolls back the transaction and the request is answered HTTP 502; `false` (default) = fire-and-forget |
253
+ | hook `timeout` | Milliseconds, default `10000` |
254
+
255
+ The request body carries the primary key (or `id`) and the target `status`.
256
+ `onBefore` runs before the UPDATE, `onAfter` after it but before COMMIT. Local
257
+ JavaScript hooks for the same operation use the `onBeforeWorkflow` /
258
+ `onAfterWorkflow` component events (see Components). The frontend buttons are
259
+ UDF `workflowActions`, a separate file — never put `workflowActions` in the RDF.
206
260
 
207
261
  ---
208
262
 
209
263
  ## Master-Detail (Composite)
210
264
 
211
- Adds `/create-composite`, `/update-composite`, and `/read-composite` endpoints.
265
+ Adds `/create-composite`, `/update-composite`, and `/read-composite`. Needs the
266
+ three composite flags in `action` and a `masterDetail` block. Generate the block
267
+ instead of writing it: `codegen_generate_payload` with `detail: "<detail table>"`
268
+ fills `masterDetail` and `detailConfig` (including `fieldValidation` and
269
+ `foreignKeys`) from the database and writes the detail query file.
212
270
 
213
271
  ```json
214
- "details": [
215
- {
216
- "tableName": "order_item",
217
- "foreignKey": "order_id",
218
- "primaryKey": "item_id",
219
- "fields": [
220
- { "fieldName": "product_id", "type": "string" },
221
- { "fieldName": "qty", "type": "integer" },
222
- { "fieldName": "price", "type": "decimal" }
223
- ]
272
+ "action": { "createComposite": true, "updateComposite": true, "readComposite": true },
273
+ "masterDetail": {
274
+ "enabled": true,
275
+ "detailTable": "stock_inbound_item",
276
+ "foreignKey": "stock_inbound_id",
277
+ "cascadeDelete": true,
278
+ "transactionMode": "required",
279
+ "detailConfig": {
280
+ "tableName": "stock_inbound_item",
281
+ "primaryKey": "stock_inbound_item_id",
282
+ "fieldName": ["stock_inbound_item_id", "stock_inbound_id", "line_number", "item_product_id", "qty_received", "unit_price", "total_amount"],
283
+ "detailQuery": "file:query/stock-inbound-detail.sql",
284
+ "requiredFields": ["line_number", "item_product_id", "qty_received", "unit_price"],
285
+ "autoCalculateFields": {
286
+ "total_amount": { "type": "calculated", "formula": "qty_received * unit_price" }
287
+ }
288
+ },
289
+ "headerCalculations": {
290
+ "total_items": { "type": "count", "source": "items.length" },
291
+ "total_amount": { "type": "sum", "source": "items.total_amount" }
224
292
  }
225
- ]
293
+ }
226
294
  ```
227
295
 
228
- - Multiple entries in `details[]` generate multiple detail tabs.
229
- - `foreignKey` links detail rows to the master record.
230
- - Detail `fields[]` follow the same validation rules as master fields.
231
- - `/update-composite` supports three detail operations in one call:
232
- `insert` (new rows), `update` (changed rows), `delete` (removed rows).
233
- - `/read-composite` returns the master record with all detail arrays nested.
296
+ | Property | Notes |
297
+ |---|---|
298
+ | `enabled` | `true` activates the feature |
299
+ | `detailTable` / `foreignKey` | Detail table and its FK column to the header |
300
+ | `cascadeDelete` | `true` = deleting the header first deletes its detail rows in the same transaction (all four dialects; no `ON DELETE CASCADE` needed) |
301
+ | `transactionMode` | Only `"required"` |
302
+ | `detailConfig.tableName`, `primaryKey`, `fieldName` | Required. Every listed column must exist in the detail table; `codegen_create_endpoint` checks this against the database |
303
+ | `detailConfig.detailQuery` | Optional SELECT for detail rows; inline or `file:` |
304
+ | `detailConfig.requiredFields` | Columns required on each detail insert |
305
+ | `detailConfig.autoCalculateFields` | Per-row values: `calculated` (formula `colA * colB`, computed by the app) or `generated` (DB GENERATED column, excluded from SQL) |
306
+ | `detailConfig.fieldValidation`, `foreignKeys` | Filled by generate; consumed by `codegen_migrate_payload` to build the UDF `details[]` |
307
+ | `headerCalculations` | Header columns computed from detail rows: `type` = `count`, `sum`, `avg`, `min`, `max`; `source` = `items.length` for count, `items.<detail column>` otherwise. The key must be a header column in root `fieldName` |
308
+
309
+ Fill `headerCalculations` and `calculated` formulas by hand after generating —
310
+ they are business decisions. `autoCalculateFields` goes **inside**
311
+ `detailConfig`; placing it next to `detailConfig` is rejected. The `_manualStub`
312
+ key written by generate is documentation only and can be removed.
313
+
314
+ At runtime a detail row field outside the detail table columns is rejected with
315
+ 400 and the whole request is rolled back. `/update-composite` with only detail
316
+ operations is valid and recomputes `headerCalculations`.
234
317
 
235
318
  ---
236
319
 
237
320
  ## Aggregate Config
238
321
 
239
- Adds an `/aggregate` endpoint for COUNT, SUM, AVG, MIN, MAX operations.
322
+ `/aggregate` runs COUNT, SUM, AVG, MIN, and MAX over the resource. It needs
323
+ `action.aggregate: true`. The **request body** carries the operations; the RDF
324
+ only declares the JOINs a request may use, so clients cannot join arbitrary
325
+ tables.
240
326
 
241
327
  ```json
328
+ "action": { "aggregate": true },
242
329
  "aggregateConfig": {
243
- "joins": [
244
- {
245
- "type": "LEFT",
246
- "table": "category",
247
- "on": "product.category_id = category.category_id"
330
+ "joins": {
331
+ "warehouse": {
332
+ "tableName": "warehouse",
333
+ "joinType": "LEFT",
334
+ "sourceField": "warehouse_id",
335
+ "targetField": "warehouse_id",
336
+ "fields": ["warehouse_code", "warehouse_name"]
248
337
  }
249
- ],
250
- "groupBy": ["category_name"],
251
- "operations": [
252
- { "function": "COUNT", "field": "product_id", "alias": "total_products" },
253
- { "function": "SUM", "field": "stock_qty", "alias": "total_stock" },
254
- { "function": "AVG", "field": "price", "alias": "avg_price" }
255
- ]
338
+ }
256
339
  }
257
340
  ```
258
341
 
259
- | Operation | Description |
342
+ | `joins.<name>` key | Notes |
260
343
  |---|---|
261
- | `COUNT` | Count rows or non-null values |
262
- | `SUM` | Sum numeric field |
263
- | `AVG` | Average numeric field |
264
- | `MIN` | Minimum value |
265
- | `MAX` | Maximum value |
344
+ | *(the key)* | Join name referenced by the request (`"joins": ["warehouse"]`) |
345
+ | `tableName` | Joined table |
346
+ | `joinType` | `INNER`, `LEFT`, or `RIGHT` (the runtime rejects `FULL`) |
347
+ | `sourceField` / `targetField` | Column on the main table / on the joined table |
348
+ | `fields` | Joined-table columns the request may use in `group_by` and `where` |
266
349
 
267
- The client sends `groupBy[]` and `having[]` in the request to filter results.
350
+ Request body:
268
351
 
269
- `joins[].on` is raw SQL: verify the join expression with `codegen_validate_sql`
270
- (wrapped in a SELECT against the same tables) before committing it to the
271
- payload.
352
+ ```json
353
+ {
354
+ "joins": ["warehouse"],
355
+ "operations": [
356
+ { "function": "sum", "field": "stock_qty", "alias": "total_stock" },
357
+ { "function": "count", "field": "*", "alias": "total_items" }
358
+ ],
359
+ "group_by": ["warehouse_name"],
360
+ "where": { "logic": "AND", "conditions": [{ "key": "is_active", "operator": "=", "value": true }] },
361
+ "having": [{ "function": "sum", "field": "stock_qty", "operator": ">", "value": 0 }]
362
+ }
363
+ ```
364
+
365
+ - Functions: `count`, `sum`, `avg`, `min`, `max` (case-insensitive). `field: "*"`
366
+ is allowed for `count` only.
367
+ - `where` uses the same structure as `/read`: `{ logic, conditions: [{ key,
368
+ operator, value }] }`, nested groups allowed; a key outside the readable and
369
+ join fields is rejected with 400. `having` operators: `=`, `<>`, `!=`, `>`,
370
+ `<`, `>=`, `<=`.
371
+ - Main-table fields must be readable fields of the resource; an alias is
372
+ alphanumeric with underscores.
373
+ - Without `operations` the endpoint returns `{ "count": N }`.
374
+ - Without `group_by` the response data is one object; with `group_by` it is an
375
+ array of rows.
376
+
377
+ For a chart or KPI built from several tables, a backend dashboard
378
+ (`codegen_create_dashboard`, SQL widgets) is usually the better fit than
379
+ `/aggregate` per resource.
272
380
 
273
381
  ---
274
382
 
275
383
  ## Adjust Config
276
384
 
277
- Adds an `/adjust` endpoint for atomic numeric field increments/decrements.
278
- Prevents race conditions on stock, balance, and counter fields.
385
+ Adds `POST /adjust` for atomic increments/decrements on numeric columns
386
+ (stock, balance, counters). Needs `action.adjust: true` and `adjustConfig`.
279
387
 
280
388
  ```json
389
+ "action": { "adjust": true },
281
390
  "adjustConfig": {
282
- "fields": ["stock_qty", "reserved_qty"],
283
- "guards": [
284
- {
285
- "field": "stock_qty",
286
- "operator": "gte",
287
- "value": 0,
288
- "message": "Stock cannot be negative"
289
- }
290
- ]
391
+ "fields": {
392
+ "stock": { "type": "number", "min": 0, "allowNegativeResult": false }
393
+ },
394
+ "reasonRequired": true
395
+ }
396
+ ```
397
+
398
+ | Property | Notes |
399
+ |---|---|
400
+ | `fields.<column>` | Column that may be adjusted. Must be a physical column of the table |
401
+ | `fields.<column>.type` | Must be `"number"` (default) |
402
+ | `fields.<column>.min` | Lower bound after the adjustment, default `0` |
403
+ | `fields.<column>.allowNegativeResult` | `false` = the UPDATE carries a guard `column + value >= min`; a violation is answered HTTP 409. Default `true` (no guard) |
404
+ | `reasonRequired` | `true` = the request must include a non-empty `reason` |
405
+
406
+ Request body:
407
+
408
+ ```json
409
+ {
410
+ "product_id": "018f...",
411
+ "adjustments": [{ "field": "stock", "value": -5 }],
412
+ "reason": "Damaged goods"
291
413
  }
292
414
  ```
293
415
 
294
- - `fields[]` — fields that can be adjusted; must be numeric type.
295
- - `guards[]` — pre-condition checks; request is rejected (HTTP 422) if any
296
- guard fails after applying the adjustment.
297
- - The client sends `{ "field": "stock_qty", "amount": -5 }` in the request.
298
- - Adjustment is executed as an atomic SQL UPDATE with WHERE guard.
416
+ `value` is a non-zero number. A column not listed in `fields`, a missing primary
417
+ key, or an empty `adjustments` array is answered HTTP 400. The adjust call fires
418
+ the `onBeforeAdjust` / `onAfterAdjust` component events.
299
419
 
300
- Guards are enforced at adjust time only. A numeric bound that must hold for every
420
+ The guard is enforced at adjust time only. A bound that must hold for every
301
421
  write path belongs in `fieldValidation` as well — check what the installed
302
422
  platform offers with `codegen_get_field_validation_catalog`.
303
423
 
@@ -305,44 +425,47 @@ platform offers with `codegen_get_field_validation_catalog`.
305
425
 
306
426
  ## Import Config
307
427
 
308
- Adds `/import-preview` and `/import-commit` endpoints for Excel (.xlsx) imports.
428
+ Excel (.xlsx) import through four endpoints: `POST /import-upload`,
429
+ `POST /import-preview`, `POST /import-commit`, `GET /import-status`. The routes
430
+ exist only when `importConfig.enabled` is `true`; set `action.import: true` too.
309
431
 
310
432
  ```json
433
+ "action": { "import": true },
311
434
  "importConfig": {
312
- "sheet": 0,
313
- "startRow": 2,
314
- "strategy": "upsert",
315
- "upsertKey": ["sku"],
316
- "columns": [
317
- { "header": "SKU", "fieldName": "sku" },
318
- { "header": "Product Name", "fieldName": "product_name" },
319
- {
320
- "header": "Category",
321
- "fieldName": "category_id",
322
- "lookup": { "apiPath": "/category", "matchField": "category_name", "returnField": "category_id" }
435
+ "enabled": true,
436
+ "upsertKeys": ["supplier_code"],
437
+ "upsertStrategy": "update_existing",
438
+ "requiredFields": ["supplier_code", "supplier_name"],
439
+ "maxFileSize": "10MB",
440
+ "allowedFormats": ["xlsx"],
441
+ "chunkSize": 100,
442
+ "lookupFields": {
443
+ "city_id": {
444
+ "targetField": "city_id",
445
+ "lookupTable": "city",
446
+ "lookupColumn": "city_name",
447
+ "lookupIdColumn": "city_id",
448
+ "required": true
323
449
  }
324
- ]
450
+ }
325
451
  }
326
452
  ```
327
453
 
328
454
  | Property | Notes |
329
455
  |---|---|
330
- | `sheet` | Sheet index (0-based) or sheet name |
331
- | `startRow` | First data row (1-based); default: `2` (row 1 = header) |
332
- | `strategy` | `"insert"` (fail on duplicate) or `"upsert"` (update on match) |
333
- | `upsertKey[]` | Fields used to identify existing records for upsert |
334
- | `columns[].header` | Excel column header text |
335
- | `columns[].fieldName` | RDF field to map to |
336
- | `columns[].lookup` | Resolve a display value to an ID before insert |
337
-
338
- Import is a two-step process: `/import-preview` validates and returns a diff;
339
- `/import-commit` applies changes. The client uploads the Excel file to `/import-preview`
340
- with a `POST multipart/form-data` request.
341
-
342
- Imported rows are checked against the `fieldValidation` of the target fields, so
343
- ground those constraints with `codegen_get_field_validation_catalog` before
344
- mapping columns — a mapping that satisfies the header names but violates a
345
- constraint fails at preview time, per row.
456
+ | `enabled` | Must be `true` to register the routes |
457
+ | `upsertKeys` | Columns that identify an existing row (default: the primary key) |
458
+ | `upsertStrategy` | `update_existing` (default), `insert_only`, or `skip_existing` |
459
+ | `requiredFields` | Columns that must be filled in every imported row |
460
+ | `maxFileSize` | Upload size limit, e.g. `"10MB"` |
461
+ | `allowedFormats` | Default `["xlsx"]` |
462
+ | `chunkSize` | Rows per INSERT/UPDATE batch, default `100` |
463
+ | `lookupFields.<name>` | Resolve a display value in the sheet to an ID: `targetField` (resource column), `lookupTable`, `lookupColumn` (value shown in Excel), `lookupIdColumn` (value stored), `required` (`true` = reject the row when no match) |
464
+
465
+ Column headers and formats follow `columnFormats` and the field labels of the
466
+ module, the same source `/export` uses, so an exported file can be re-imported.
467
+ `fieldValidation` applies to `/create` and `/update` only, not to import; use
468
+ `requiredFields` and `lookupFields.required` for import-time checks.
346
469
 
347
470
  ---
348
471
 
@@ -430,32 +553,34 @@ payload as a bare file name (no path form), unlike the dashboard generators.
430
553
 
431
554
  ## Kafka Event Publishing
432
555
 
433
- Publishes events to a Kafka topic after CRUD operations. Requires
434
- `KAFKA_ENABLED=true` in backend config.
556
+ Publishes an event to one Kafka topic after insert, update, or delete. Requires
557
+ `KAFKA_ENABLED=true` in the backend config as well.
435
558
 
436
559
  ```json
437
560
  "kafka": {
438
- "events": [
439
- { "action": "create", "topic": "order.created.events" },
440
- { "action": "update", "topic": "order.updated.events" },
441
- { "action": "delete", "topic": "order.deleted.events" }
442
- ]
561
+ "enabled": true,
562
+ "topic": "inventory.stock_inbound",
563
+ "keyField": "stock_inbound_id",
564
+ "publishOn": { "insert": true, "update": true, "delete": false }
443
565
  }
444
566
  ```
445
567
 
446
- - `action` — CRUD action that triggers the event: `create`, `update`, `delete`,
447
- `change-status`, `create-composite`, `update-composite`.
448
- - `topic` — Kafka topic name. Supports `{module}` and `{endpoint}` placeholders:
449
- `"{module}.{endpoint}.events"`.
450
- - Event payload contains the full record after the operation.
451
- - Publishing is async and does not block the API response.
568
+ | Property | Notes |
569
+ |---|---|
570
+ | `enabled` | `true` activates publishing for this resource |
571
+ | `topic` | Target topic name |
572
+ | `keyField` | Record field used as the message key (default: the primary key) |
573
+ | `publishOn.insert` / `update` / `delete` | Which operations publish |
574
+
575
+ There is one topic per resource and no per-action topic list. To consume events,
576
+ generate a consumer with `codegen_create_kafka_consumer`.
452
577
 
453
578
  ---
454
579
 
455
580
  ## Components (Lifecycle Hooks)
456
581
 
457
- `components` configures CRUD lifecycle hooks that execute local JavaScript handler
458
- files. Added to a standard CRUD payload (one that has `tableName`).
582
+ `components` binds CRUD lifecycle events to functions in local JavaScript
583
+ handler files. Added to a standard CRUD payload (one that has `tableName`).
459
584
 
460
585
  ```json
461
586
  {
@@ -474,7 +599,8 @@ files. Added to a standard CRUD payload (one that has `tableName`).
474
599
  },
475
600
  {
476
601
  "name": "notifySlack",
477
- "events": "onAfterInsert"
602
+ "events": "onAfterInsert",
603
+ "params": [{ "value": "{newData}" }]
478
604
  }
479
605
  ]
480
606
  }
@@ -485,11 +611,11 @@ files. Added to a standard CRUD payload (one that has `tableName`).
485
611
 
486
612
  | Property | Required | Notes |
487
613
  |---|---|---|
488
- | `components[].properties.filename` | Yes | Path to handler file, relative to project root |
614
+ | `components[].properties.filename` | Yes | Handler path **relative to `src/`**: `components/supplier-hooks.js` loads `src/components/supplier-hooks.js`. `..`, absolute paths, and characters outside `[a-zA-Z0-9._/-]` are rejected |
489
615
  | `components[].properties.methods` | Yes | List of method bindings |
490
616
  | `methods[].name` | Yes | Function name exported from the handler file |
491
617
  | `methods[].events` | Yes | Event hook (see table below) |
492
- | `methods[].params` | No | Template variables forwarded to the handler |
618
+ | `methods[].params` | Yes in practice | Template variables forwarded to the handler, in order. Always write the array (use `[]` for none): a binding without `params` fails when the event fires |
493
619
 
494
620
  **Supported event hooks:**
495
621
 
@@ -500,6 +626,8 @@ files. Added to a standard CRUD payload (one that has `tableName`).
500
626
  | `onBeforeDelete`, `onAfterDelete` | `/delete` endpoint |
501
627
  | `onBeforeCompositeInsert`, `onAfterCompositeInsert` | `/create-composite` endpoint |
502
628
  | `onBeforeCompositeUpdate`, `onAfterCompositeUpdate` | `/update-composite` endpoint |
629
+ | `onBeforeAdjust`, `onAfterAdjust` | `/adjust` endpoint |
630
+ | `onBeforeWorkflow`, `onAfterWorkflow` | `/change-status` endpoint |
503
631
 
504
632
  **Template variables for `params[].value`:**
505
633
 
@@ -514,25 +642,54 @@ files. Added to a standard CRUD payload (one that has `tableName`).
514
642
  | `{timestamp}` | Execution timestamp |
515
643
  | `{record_id}` | Primary key of the affected record |
516
644
 
517
- **Handler file signature** (`src/components/handlers/`):
645
+ A value that is exactly one variable passes the raw value (object, array);
646
+ a string mixing text and variables is interpolated.
647
+
648
+ **Handler file signature** (e.g. `src/components/supplier-hooks.js`):
518
649
 
519
650
  ```javascript
520
- async function handlerName(/* resolved params... */, services) {
651
+ async function validateSupplierCode(requestData, userId, services) {
521
652
  const { db, logger, redis, kafka, cache } = services;
522
- // business logic
523
- return { success: true, message: '...' };
653
+ if (!/^[A-Z]{3}\d{3}$/.test(requestData.supplier_code)) {
654
+ return { success: false, message: 'Supplier code must look like ABC123' };
655
+ }
656
+ return { success: true };
524
657
  }
525
- module.exports = { handlerName };
658
+ module.exports = { validateSupplierCode };
526
659
  ```
527
660
 
661
+ - `services` is injected automatically as the last argument; no need to declare
662
+ it in `params[]`. It also carries `idempotency`, `idgen`, `storage`,
663
+ `websocket`, `createResponse`, `createError`, and `createValidationError`.
664
+ - All events are **blocking**, including the `onAfter*` ones. Returning
665
+ `{ success: false, message }` or throwing rolls back the whole transaction.
666
+ - A rejection is answered **HTTP 400** with the hook's own `message` only; the
667
+ function name and file path are written to the server log, not to the client.
668
+ - If `components` is absent from the payload, CRUD operates normally without hooks.
669
+
528
670
  A lifecycle hook is not a substitute for declared validation: keep rule checks
529
671
  that `fieldValidation` can express in the payload, grounded with
530
672
  `codegen_get_field_validation_catalog`, and reserve `components` for logic the
531
673
  catalog genuinely cannot express (external calls, cross-table effects,
532
- notifications).
674
+ notifications, status locks).
533
675
 
534
- - `services` is injected automatically as the last argument; no need to declare it
535
- in `params[]`.
536
- - All events are **blocking** — `return { success: false }` or throwing an exception
537
- rolls back the entire transaction.
538
- - If `components` is absent from the payload, CRUD operates normally without hooks.
676
+ ---
677
+
678
+ ## Other RDF Blocks
679
+
680
+ These blocks are read by the payload validator or the generator. Most are
681
+ schema-derived and written by `codegen_generate_payload`; the handbook pages
682
+ under `restforge-handbook/catalogs/rdf/` hold the full rules.
683
+
684
+ | Block | Purpose | Written by |
685
+ |---|---|---|
686
+ | `dateTimeFields` | Per-column `type` (`date`, `timestamp`, `timestamptz`, `time`) so the runtime normalises input and formats output. `format` is allowed for `time` only; `date`/`timestamp` always follow `DATEFORMAT`/`DATETIMEFORMAT` | generate |
687
+ | `uniqueConstraints` | `{name, fields}` from the database; names the conflicting field in a 409 response | generate / sync |
688
+ | `deleteReferences` | Child tables that block a delete (`{table, column, references}`); used in the 409 response of `/delete` | generate |
689
+ | `softDelete` (+ `softDeleteFk*` metadata) | Soft-delete behaviour derived from the SDF. Only `visibility` (`active_only`, `deleted_only`, `include_deleted`) is meant to be edited | generate |
690
+ | `auditColumns` | `false`/`null` disables, object renames the four audit columns | manual override |
691
+ | `concurrency` | Optimistic concurrency for `update`/`update-composite`: `{versionColumn, compare}`; `compare` = `version` (integer column, default) or `timestamp` (PostgreSQL only). Pairs with UDF `versionField` | manual |
692
+ | `fieldPolicy` | Per-column `strategies`: `lock` (SELECT ... FOR UPDATE) and/or `audit` (writes `<table>_audit`); `"*": {"strategies": ["audit"]}` audits every column. Replaces `fieldProtection` | manual |
693
+ | `uploadConfig` | File fields (JSON columns) with `maxFiles`, `maxFileSize`, `allowedTypes`, `allowedMimeTypes`, `storagePrefix`; needs `action.upload: true` | manual |
694
+ | `authGuard` | `{enabled, appCode, publicPaths}`: JWT verification and per-endpoint permission; generates `src/plugins/<project>-auth-guard.js` | manual |
695
+ | `columnFormats` | Excel column formats shared by `/export` and import | manual |