create-restforge-skills 0.2.0 → 0.4.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,488 +1,695 @@
1
- # Reference: RDF Advanced Features
2
-
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`.
8
-
9
- This reference covers advanced RDF payload features beyond standard CRUD fields.
10
-
11
- ---
12
-
13
- ## Table of Contents
14
-
15
- 1. [Data Source Resolution](#data-source-resolution)
16
- 2. [Query File Reference](#query-file-reference)
17
- 3. [Field Lookup](#field-lookup)
18
- 4. [Default Scope](#default-scope)
19
- 5. [Workflow (Change-Status)](#workflow-change-status)
20
- 6. [Master-Detail (Composite)](#master-detail-composite)
21
- 7. [Aggregate Config](#aggregate-config)
22
- 8. [Adjust Config](#adjust-config)
23
- 9. [Import Config](#import-config)
24
- 10. [Processor](#processor)
25
- 11. [Kafka Event Publishing](#kafka-event-publishing)
26
- 12. [Components (Lifecycle Hooks)](#components-lifecycle-hooks)
27
-
28
- ---
29
-
30
- ## Data Source Resolution
31
-
32
- RESTForge resolves data sources per endpoint using a priority chain. Define
33
- only what is needed; the platform falls back automatically.
34
-
35
- | Endpoint | Resolution order |
36
- |---|---|
37
- | `/datatables` | `datatablesQuery` → `SELECT * FROM tableName` |
38
- | `/read`, `/first`, `/lookup` | `viewName` → `viewQuery` → `tableName` |
39
- | `/export` | `exportQuery` → `SELECT {fields} FROM tableName` |
40
- | `/read-composite` (detail) | `detailQuery` → detail `tableName` |
41
-
42
- **`viewName`** — reference a database VIEW:
43
- ```json
44
- "viewName": "v_order_summary"
45
- ```
46
-
47
- **`viewQuery`** — inline SQL (virtual view, no DB object created):
48
- ```json
49
- "viewQuery": "SELECT o.*, c.customer_name FROM orders o JOIN customers c ON o.customer_id = c.customer_id"
50
- ```
51
-
52
- **`datatablesQuery`** — SQL for the paginated table with `:search`, `:sort`,
53
- `:limit`, `:offset` placeholders:
54
- ```json
55
- "datatablesQuery": "SELECT o.*, c.customer_name FROM orders o JOIN customers c ON o.customer_id = c.customer_id WHERE 1=1"
56
- ```
57
-
58
- Write source is always `tableName` — `viewName`/`viewQuery` are read-only.
59
-
60
- ---
61
-
62
- ## Query File Reference
63
-
64
- SQL queries can be stored in external `.sql` files using the `file:` prefix.
65
- Path is relative to the payload file location.
66
-
67
- ```json
68
- "datatablesQuery": "file:sql/orders-datatables.sql",
69
- "exportQuery": "file:sql/orders-export.sql"
70
- ```
71
-
72
- Convention for folder structure:
73
- ```
74
- payload/
75
- ├── order.json
76
- └── sql/
77
- ├── orders-datatables.sql
78
- └── orders-export.sql
79
- ```
80
-
81
- External SQL files support the same placeholders as inline queries.
82
- For master-detail, each detail query is in a separate file.
83
-
84
- ---
85
-
86
- ## Field Lookup
87
-
88
- Configures dropdown/autocomplete data for a field. Used for foreign key fields
89
- that need a human-readable label.
90
-
91
- ```json
92
- {
93
- "fieldName": "category_id",
94
- "type": "string",
95
- "fieldLookup": {
96
- "apiPath": "/category",
97
- "id": "category_id",
98
- "text": "category_name"
99
- }
100
- }
101
- ```
102
-
103
- - `apiPath` — backend resource that provides lookup options via `/lookup` endpoint.
104
- - `id` — field returned as the stored value.
105
- - `text` — field returned as the display label.
106
-
107
- Static lookup (no API call):
108
- ```json
109
- "fieldLookup": {
110
- "type": "static",
111
- "options": [
112
- { "id": "A", "text": "Option A" },
113
- { "id": "B", "text": "Option B" }
114
- ]
115
- }
116
- ```
117
-
118
- ---
119
-
120
- ## Default Scope
121
-
122
- Automatic WHERE clause injected on `/lookup` and `/read`-family endpoints.
123
- Used for tenant isolation, user-scoped data, or active record filtering.
124
-
125
- ```json
126
- "defaultScope": {
127
- "actions": ["lookup", "read", "datatables"],
128
- "conditions": [
129
- { "key": "is_active", "value": true },
130
- { "key": "company_id", "value": ":companyId" }
131
- ]
132
- }
133
- ```
134
-
135
- - `actions[]` — which endpoints apply the scope.
136
- - Conditions with `:paramName` resolve from the request context (e.g., JWT claims).
137
- - Combines with user-supplied WHERE via AND.
138
- - The `is_active` column is auto-synced by the processor's `.active()` method.
139
-
140
- ---
141
-
142
- ## Workflow (Change-Status)
143
-
144
- Adds a `/change-status` endpoint with state machine validation.
145
-
146
- ```json
147
- "workflow": {
148
- "statusField": "status",
149
- "transitions": [
150
- {
151
- "from": "draft",
152
- "to": "submitted",
153
- "action": "submit",
154
- "onBefore": "http://internal-service/validate",
155
- "onAfter": "http://notification-service/notify"
156
- },
157
- {
158
- "from": "submitted",
159
- "to": "approved",
160
- "action": "approve"
161
- },
162
- {
163
- "from": ["submitted", "approved"],
164
- "to": "rejected",
165
- "action": "reject"
166
- }
167
- ]
168
- }
169
- ```
170
-
171
- | Property | Notes |
172
- |---|---|
173
- | `statusField` | Field name that holds the current status |
174
- | `transitions[].from` | Current status (string or array of strings) |
175
- | `transitions[].to` | Target status after transition |
176
- | `transitions[].action` | Action identifier in the request body |
177
- | `transitions[].onBefore` | HTTP call before transition; 4xx/5xx blocks the transition (HTTP 422/502) |
178
- | `transitions[].onAfter` | HTTP call after transition; failure does not roll back |
179
-
180
- ---
181
-
182
- ## Master-Detail (Composite)
183
-
184
- Adds `/create-composite`, `/update-composite`, and `/read-composite` endpoints.
185
-
186
- ```json
187
- "details": [
188
- {
189
- "tableName": "order_item",
190
- "foreignKey": "order_id",
191
- "primaryKey": "item_id",
192
- "fields": [
193
- { "fieldName": "product_id", "type": "string" },
194
- { "fieldName": "qty", "type": "integer" },
195
- { "fieldName": "price", "type": "decimal" }
196
- ]
197
- }
198
- ]
199
- ```
200
-
201
- - Multiple entries in `details[]` generate multiple detail tabs.
202
- - `foreignKey` links detail rows to the master record.
203
- - Detail `fields[]` follow the same validation rules as master fields.
204
- - `/update-composite` supports three detail operations in one call:
205
- `insert` (new rows), `update` (changed rows), `delete` (removed rows).
206
- - `/read-composite` returns the master record with all detail arrays nested.
207
-
208
- ---
209
-
210
- ## Aggregate Config
211
-
212
- Adds an `/aggregate` endpoint for COUNT, SUM, AVG, MIN, MAX operations.
213
-
214
- ```json
215
- "aggregateConfig": {
216
- "joins": [
217
- {
218
- "type": "LEFT",
219
- "table": "category",
220
- "on": "product.category_id = category.category_id"
221
- }
222
- ],
223
- "groupBy": ["category_name"],
224
- "operations": [
225
- { "function": "COUNT", "field": "product_id", "alias": "total_products" },
226
- { "function": "SUM", "field": "stock_qty", "alias": "total_stock" },
227
- { "function": "AVG", "field": "price", "alias": "avg_price" }
228
- ]
229
- }
230
- ```
231
-
232
- | Operation | Description |
233
- |---|---|
234
- | `COUNT` | Count rows or non-null values |
235
- | `SUM` | Sum numeric field |
236
- | `AVG` | Average numeric field |
237
- | `MIN` | Minimum value |
238
- | `MAX` | Maximum value |
239
-
240
- The client sends `groupBy[]` and `having[]` in the request to filter results.
241
-
242
- ---
243
-
244
- ## Adjust Config
245
-
246
- Adds an `/adjust` endpoint for atomic numeric field increments/decrements.
247
- Prevents race conditions on stock, balance, and counter fields.
248
-
249
- ```json
250
- "adjustConfig": {
251
- "fields": ["stock_qty", "reserved_qty"],
252
- "guards": [
253
- {
254
- "field": "stock_qty",
255
- "operator": "gte",
256
- "value": 0,
257
- "message": "Stock cannot be negative"
258
- }
259
- ]
260
- }
261
- ```
262
-
263
- - `fields[]` — fields that can be adjusted; must be numeric type.
264
- - `guards[]` — pre-condition checks; request is rejected (HTTP 422) if any
265
- guard fails after applying the adjustment.
266
- - The client sends `{ "field": "stock_qty", "amount": -5 }` in the request.
267
- - Adjustment is executed as an atomic SQL UPDATE with WHERE guard.
268
-
269
- ---
270
-
271
- ## Import Config
272
-
273
- Adds `/import-preview` and `/import-commit` endpoints for Excel (.xlsx) imports.
274
-
275
- ```json
276
- "importConfig": {
277
- "sheet": 0,
278
- "startRow": 2,
279
- "strategy": "upsert",
280
- "upsertKey": ["sku"],
281
- "columns": [
282
- { "header": "SKU", "fieldName": "sku" },
283
- { "header": "Product Name", "fieldName": "product_name" },
284
- {
285
- "header": "Category",
286
- "fieldName": "category_id",
287
- "lookup": { "apiPath": "/category", "matchField": "category_name", "returnField": "category_id" }
288
- }
289
- ]
290
- }
291
- ```
292
-
293
- | Property | Notes |
294
- |---|---|
295
- | `sheet` | Sheet index (0-based) or sheet name |
296
- | `startRow` | First data row (1-based); default: `2` (row 1 = header) |
297
- | `strategy` | `"insert"` (fail on duplicate) or `"upsert"` (update on match) |
298
- | `upsertKey[]` | Fields used to identify existing records for upsert |
299
- | `columns[].header` | Excel column header text |
300
- | `columns[].fieldName` | RDF field to map to |
301
- | `columns[].lookup` | Resolve a display value to an ID before insert |
302
-
303
- Import is a two-step process: `/import-preview` validates and returns a diff;
304
- `/import-commit` applies changes. The client uploads the Excel file to `/import-preview`
305
- with a `POST multipart/form-data` request.
306
-
307
- ---
308
-
309
- ## Processor
310
-
311
- Alternative RDF structure for custom non-CRUD endpoints. A processor payload
312
- does NOT have `tableName`, `fieldName`, or `action`. Each entry in `processor[]`
313
- defines one endpoint. Generated with `npx restforge processor create`.
314
-
315
- ```json
316
- {
317
- "description": "Sales Order custom endpoints",
318
- "processor": [
319
- {
320
- "name": "submit-order",
321
- "method": "POST",
322
- "description": "Submit a draft order to pending approval",
323
- "sql": {
324
- "query": "UPDATE sales.sales_order SET status = 'pending_approval' WHERE so_id = $1 AND status = 'draft'",
325
- "params": ["so_id"]
326
- },
327
- "request": {
328
- "body": {
329
- "so_id": { "type": "uuid", "required": true },
330
- "notes": { "type": "string", "required": false, "maxLength": 200 }
331
- },
332
- "headers": {
333
- "X-App-Code": { "type": "string", "required": true, "mapTo": "app_code" }
334
- }
335
- },
336
- "response": {
337
- "message": {
338
- "success": "Sales order submitted for approval.",
339
- "empty": "Sales order not found or not in draft status.",
340
- "error": "Failed to submit sales order."
341
- }
342
- }
343
- }
344
- ]
345
- }
346
- ```
347
-
348
- | Property | Required | Notes |
349
- |---|---|---|
350
- | `processor[].name` | Yes | Endpoint name — becomes file name and URL segment |
351
- | `processor[].method` | Yes | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` |
352
- | `processor[].sql.query` | Conditional | Inline SQL with `$1, $2, ...` placeholders. If `sql` block present, one of `query` or `file` is required |
353
- | `processor[].sql.file` | Conditional | Path to external `.sql` file, relative to payload folder |
354
- | `processor[].sql.params` | No | Field names bound to placeholders; resolved from body/params/query/header `mapTo`; falls back to `default` if input empty |
355
- | `processor[].request.body` | No | Request body field schema |
356
- | `processor[].request.params` | No | Route params; each key adds `/:key` to the path |
357
- | `processor[].request.headers` | No | Header schema; use `mapTo` to rename into `input` |
358
- | `processor[].request.validate` | No | Default `true`. Set `false` to opt-out router-level validation |
359
- | `processor[].response.message` | No | `success`, `empty` (SQL mode only), `error` messages |
360
- | `processor[].cache.enabled` | No | Default `false`. Response cache for GET processors |
361
- | `processor[].cache.ttl` | No | Cache TTL in seconds; default `300` |
362
-
363
- **Field schema properties** (apply to `request.body`, `request.params`, `request.headers`):
364
-
365
- | Property | Notes |
366
- |---|---|
367
- | `type` | `string`, `number`, `integer`, `boolean`, `uuid`, `array`, `object`, `date`, `datetime` |
368
- | `required` | Router rejects with HTTP 400 if absent |
369
- | `format` | Regex whitelist: `email`, `url`, `phone-id`, `uuid` |
370
- | `enum` | Whitelist of allowed values |
371
- | `minLength` / `maxLength` | Length check for string fields |
372
- | `sensitive` | `true` masks value as `***MASKED***` in router debug log |
373
- | `default` | Fallback value for `sql.params` binding when input is empty |
374
- | `mapTo` | (`headers` only) field name to use in `input` object |
375
-
376
- **Generator behavior:**
377
- - Router (`{endpoint}.js`) — always overwritten on re-run.
378
- - Processor file (`processor/{endpoint}/{name}.js`) — skipped if already exists (safe to re-run).
379
- Use `--force` to overwrite.
380
-
381
- **Without sql block** — payload with only `name`, `method`, and `request` is valid.
382
- Router registers the route with validation; processor file is generated as a manual
383
- implementation scaffold. Business logic is written in the processor file.
384
-
385
- ---
386
-
387
- ## Kafka Event Publishing
388
-
389
- Publishes events to a Kafka topic after CRUD operations. Requires
390
- `KAFKA_ENABLED=true` in backend config.
391
-
392
- ```json
393
- "kafka": {
394
- "events": [
395
- { "action": "create", "topic": "order.created.events" },
396
- { "action": "update", "topic": "order.updated.events" },
397
- { "action": "delete", "topic": "order.deleted.events" }
398
- ]
399
- }
400
- ```
401
-
402
- - `action` — CRUD action that triggers the event: `create`, `update`, `delete`,
403
- `change-status`, `create-composite`, `update-composite`.
404
- - `topic` — Kafka topic name. Supports `{module}` and `{endpoint}` placeholders:
405
- `"{module}.{endpoint}.events"`.
406
- - Event payload contains the full record after the operation.
407
- - Publishing is async and does not block the API response.
408
-
409
- ---
410
-
411
- ## Components (Lifecycle Hooks)
412
-
413
- `components` configures CRUD lifecycle hooks that execute local JavaScript handler
414
- files. Added to a standard CRUD payload (one that has `tableName`).
415
-
416
- ```json
417
- {
418
- "components": [
419
- {
420
- "properties": {
421
- "filename": "components/supplier-hooks.js",
422
- "methods": [
423
- {
424
- "name": "validateSupplierCode",
425
- "events": "onBeforeInsert",
426
- "params": [
427
- { "value": "{requestData}" },
428
- { "value": "{user_id}" }
429
- ]
430
- },
431
- {
432
- "name": "notifySlack",
433
- "events": "onAfterInsert"
434
- }
435
- ]
436
- }
437
- }
438
- ]
439
- }
440
- ```
441
-
442
- | Property | Required | Notes |
443
- |---|---|---|
444
- | `components[].properties.filename` | Yes | Path to handler file, relative to project root |
445
- | `components[].properties.methods` | Yes | List of method bindings |
446
- | `methods[].name` | Yes | Function name exported from the handler file |
447
- | `methods[].events` | Yes | Event hook (see table below) |
448
- | `methods[].params` | No | Template variables forwarded to the handler |
449
-
450
- **Supported event hooks:**
451
-
452
- | Event | Trigger |
453
- |---|---|
454
- | `onBeforeInsert`, `onAfterInsert` | `/create` endpoint |
455
- | `onBeforeUpdate`, `onAfterUpdate` | `/update` endpoint |
456
- | `onBeforeDelete`, `onAfterDelete` | `/delete` endpoint |
457
- | `onBeforeCompositeInsert`, `onAfterCompositeInsert` | `/create-composite` endpoint |
458
- | `onBeforeCompositeUpdate`, `onAfterCompositeUpdate` | `/update-composite` endpoint |
459
-
460
- **Template variables for `params[].value`:**
461
-
462
- | Variable | Value |
463
- |---|---|
464
- | `{tableName}` | Resource table name |
465
- | `{requestData}` | Full request body |
466
- | `{oldData}` | Data before operation (`update`, `delete`) |
467
- | `{newData}` | Data after operation (`create`, `update`) |
468
- | `{operation}` | Operation name: `insert` / `update` / `delete` |
469
- | `{user_id}` | User ID from request context |
470
- | `{timestamp}` | Execution timestamp |
471
- | `{record_id}` | Primary key of the affected record |
472
-
473
- **Handler file signature** (`src/components/handlers/`):
474
-
475
- ```javascript
476
- async function handlerName(/* resolved params... */, services) {
477
- const { db, logger, redis, kafka, cache } = services;
478
- // business logic
479
- return { success: true, message: '...' };
480
- }
481
- module.exports = { handlerName };
482
- ```
483
-
484
- - `services` is injected automatically as the last argument; no need to declare it
485
- in `params[]`.
486
- - All events are **blocking** — `return { success: false }` or throwing an exception
487
- rolls back the entire transaction.
488
- - If `components` is absent from the payload, CRUD operates normally without hooks.
1
+ # Reference: RDF Advanced Features
2
+
3
+ > **Offline mirror.** This file mirrors the RDF catalog of the installed
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
+
9
+ This reference covers advanced RDF payload features beyond standard CRUD fields.
10
+
11
+ **Grounding tools for this file.** Reading this reference is not grounding — it
12
+ explains the shapes, the tools return what the *installed* platform accepts:
13
+
14
+ | Editing | Call first |
15
+ |---|---|
16
+ | `fieldValidation` on master or detail columns | `codegen_get_field_validation_catalog` |
17
+ | `datatablesQuery`, `viewQuery`, `viewName`, `exportQuery`, `detailQuery`, and `file:` query references | `codegen_get_query_declarative_catalog` |
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
+
20
+ Whatever the section, the finished payload goes through `codegen_validate_payload`
21
+ before `codegen_create_endpoint` — a processor payload through
22
+ `codegen_create_processor`.
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
+
31
+ ---
32
+
33
+ ## Table of Contents
34
+
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.
76
+
77
+ ---
78
+
79
+ ## Data Source Resolution
80
+
81
+ RESTForge resolves data sources per endpoint using a priority chain. Define
82
+ only what is needed; the platform falls back automatically.
83
+
84
+ | Endpoint | Resolution order |
85
+ |---|---|
86
+ | `/datatables` | `datatablesQuery` → `SELECT * FROM` (`viewName` or `tableName`) |
87
+ | `/read`, `/first`, `/lookup` | `viewName` → `viewQuery` → `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}` |
90
+
91
+ **`viewName`** — reference a database VIEW:
92
+ ```json
93
+ "viewName": "v_order_summary"
94
+ ```
95
+
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:
98
+ ```json
99
+ "viewQuery": "SELECT o.*, c.customer_name FROM orders o JOIN customers c ON o.customer_id = c.customer_id"
100
+ ```
101
+
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.
114
+ ```json
115
+ "datatablesWhere": ["supplier_code", "supplier_name", "all"]
116
+ ```
117
+
118
+ Write source is always `tableName` — `viewName`/`viewQuery` are read-only.
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
+
125
+ Ground every one of these keys with `codegen_get_query_declarative_catalog`
126
+ before writing them, and run the SQL through `codegen_validate_sql` first: a
127
+ JOIN or column typo here surfaces as a runtime 500 on `/datatables` or `/export`,
128
+ not as a payload validation error.
129
+
130
+ ---
131
+
132
+ ## Query File Reference
133
+
134
+ SQL queries can be stored in external `.sql` files using the `file:` prefix.
135
+ The path is relative to the `payload/` folder.
136
+
137
+ ```json
138
+ "datatablesQuery": "file:query/orders-datatables.sql",
139
+ "exportQuery": "file:query/orders-export.sql"
140
+ ```
141
+
142
+ Convention for folder structure:
143
+ ```
144
+ payload/
145
+ ├── order.json
146
+ └── query/
147
+ ├── orders-datatables.sql
148
+ └── orders-export.sql
149
+ ```
150
+
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.
154
+
155
+ The `file:` form hides the SQL from a quick payload review, which makes
156
+ `codegen_validate_sql` on the file content worth more here than for inline
157
+ queries.
158
+
159
+ ---
160
+
161
+ ## Field Lookup
162
+
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.
166
+
167
+ ```json
168
+ "fieldNameLookup": {
169
+ "id": "category_id",
170
+ "text": "category_code||' - '||category_name as display_text"
171
+ }
172
+ ```
173
+
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 |
178
+
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`.
183
+
184
+ ---
185
+
186
+ ## Default Scope
187
+
188
+ Automatic WHERE filter injected on the `lookup` and `read` actions only.
189
+ `/datatables` and `/first` are not affected.
190
+
191
+ ```json
192
+ "defaultScope": {
193
+ "lookup": { "is_active": true },
194
+ "read": { "is_active": true }
195
+ }
196
+ ```
197
+
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.
207
+
208
+ ---
209
+
210
+ ## Workflow (Change-Status)
211
+
212
+ Adds a `POST /change-status` endpoint with state machine validation. Needs both
213
+ `action.workflow: true` and the `workflow` block.
214
+
215
+ ```json
216
+ "action": { "workflow": true },
217
+ "workflow": {
218
+ "statusField": "status",
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
+ ]
238
+ }
239
+ }
240
+ }
241
+ ```
242
+
243
+ | Property | Notes |
244
+ |---|---|
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.
260
+
261
+ ---
262
+
263
+ ## Master-Detail (Composite)
264
+
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.
270
+
271
+ ```json
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" }
292
+ }
293
+ }
294
+ ```
295
+
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`.
317
+
318
+ ---
319
+
320
+ ## Aggregate Config
321
+
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.
326
+
327
+ ```json
328
+ "action": { "aggregate": true },
329
+ "aggregateConfig": {
330
+ "joins": {
331
+ "warehouse": {
332
+ "tableName": "warehouse",
333
+ "joinType": "LEFT",
334
+ "sourceField": "warehouse_id",
335
+ "targetField": "warehouse_id",
336
+ "fields": ["warehouse_code", "warehouse_name"]
337
+ }
338
+ }
339
+ }
340
+ ```
341
+
342
+ | `joins.<name>` key | Notes |
343
+ |---|---|
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` |
349
+
350
+ Request body:
351
+
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.
380
+
381
+ ---
382
+
383
+ ## Adjust Config
384
+
385
+ Adds `POST /adjust` for atomic increments/decrements on numeric columns
386
+ (stock, balance, counters). Needs `action.adjust: true` and `adjustConfig`.
387
+
388
+ ```json
389
+ "action": { "adjust": true },
390
+ "adjustConfig": {
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"
413
+ }
414
+ ```
415
+
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.
419
+
420
+ The guard is enforced at adjust time only. A bound that must hold for every
421
+ write path belongs in `fieldValidation` as well — check what the installed
422
+ platform offers with `codegen_get_field_validation_catalog`.
423
+
424
+ ---
425
+
426
+ ## Import Config
427
+
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.
431
+
432
+ ```json
433
+ "action": { "import": true },
434
+ "importConfig": {
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
449
+ }
450
+ }
451
+ }
452
+ ```
453
+
454
+ | Property | Notes |
455
+ |---|---|
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.
469
+
470
+ ---
471
+
472
+ ## Processor
473
+
474
+ Alternative RDF structure for custom non-CRUD endpoints. A processor payload
475
+ does NOT have `tableName`, `fieldName`, or `action`. Each entry in `processor[]`
476
+ defines one endpoint. Generated with `npx restforge processor create`.
477
+
478
+ ```json
479
+ {
480
+ "description": "Sales Order custom endpoints",
481
+ "processor": [
482
+ {
483
+ "name": "submit-order",
484
+ "method": "POST",
485
+ "description": "Submit a draft order to pending approval",
486
+ "sql": {
487
+ "query": "UPDATE sales.sales_order SET status = 'pending_approval' WHERE so_id = $1 AND status = 'draft'",
488
+ "params": ["so_id"]
489
+ },
490
+ "request": {
491
+ "body": {
492
+ "so_id": { "type": "uuid", "required": true },
493
+ "notes": { "type": "string", "required": false, "maxLength": 200 }
494
+ },
495
+ "headers": {
496
+ "X-App-Code": { "type": "string", "required": true, "mapTo": "app_code" }
497
+ }
498
+ },
499
+ "response": {
500
+ "message": {
501
+ "success": "Sales order submitted for approval.",
502
+ "empty": "Sales order not found or not in draft status.",
503
+ "error": "Failed to submit sales order."
504
+ }
505
+ }
506
+ }
507
+ ]
508
+ }
509
+ ```
510
+
511
+ | Property | Required | Notes |
512
+ |---|---|---|
513
+ | `processor[].name` | Yes | Endpoint name — becomes file name and URL segment |
514
+ | `processor[].method` | Yes | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` |
515
+ | `processor[].sql.query` | Conditional | Inline SQL with `$1, $2, ...` placeholders. If `sql` block present, one of `query` or `file` is required |
516
+ | `processor[].sql.file` | Conditional | Path to external `.sql` file, relative to payload folder |
517
+ | `processor[].sql.params` | No | Field names bound to placeholders; resolved from body/params/query/header `mapTo`; falls back to `default` if input empty |
518
+ | `processor[].request.body` | No | Request body field schema |
519
+ | `processor[].request.params` | No | Route params; each key adds `/:key` to the path |
520
+ | `processor[].request.headers` | No | Header schema; use `mapTo` to rename into `input` |
521
+ | `processor[].request.validate` | No | Default `true`. Set `false` to opt-out router-level validation |
522
+ | `processor[].response.message` | No | `success`, `empty` (SQL mode only), `error` messages |
523
+ | `processor[].cache.enabled` | No | Default `false`. Response cache for GET processors |
524
+ | `processor[].cache.ttl` | No | Cache TTL in seconds; default `300` |
525
+
526
+ **Field schema properties** (apply to `request.body`, `request.params`, `request.headers`):
527
+
528
+ | Property | Notes |
529
+ |---|---|
530
+ | `type` | `string`, `number`, `integer`, `boolean`, `uuid`, `array`, `object`, `date`, `datetime` |
531
+ | `required` | Router rejects with HTTP 400 if absent |
532
+ | `format` | Regex whitelist: `email`, `url`, `phone-id`, `uuid` |
533
+ | `enum` | Whitelist of allowed values |
534
+ | `minLength` / `maxLength` | Length check for string fields |
535
+ | `sensitive` | `true` masks value as `***MASKED***` in router debug log |
536
+ | `default` | Fallback value for `sql.params` binding when input is empty |
537
+ | `mapTo` | (`headers` only) field name to use in `input` object |
538
+
539
+ **Generator behavior:**
540
+ - Router (`{endpoint}.js`) — always overwritten on re-run.
541
+ - Processor file (`processor/{endpoint}/{name}.js`) — skipped if already exists (safe to re-run).
542
+ Use `--force` to overwrite.
543
+
544
+ **Without sql block** — payload with only `name`, `method`, and `request` is valid.
545
+ Router registers the route with validation; processor file is generated as a manual
546
+ implementation scaffold. Business logic is written in the processor file.
547
+
548
+ `sql.query` is executed as written: run it through `codegen_validate_sql` first
549
+ when it is a SELECT, and keep in mind that `codegen_create_processor` takes the
550
+ payload as a bare file name (no path form), unlike the dashboard generators.
551
+
552
+ ---
553
+
554
+ ## Kafka Event Publishing
555
+
556
+ Publishes an event to one Kafka topic after insert, update, or delete. Requires
557
+ `KAFKA_ENABLED=true` in the backend config as well.
558
+
559
+ ```json
560
+ "kafka": {
561
+ "enabled": true,
562
+ "topic": "inventory.stock_inbound",
563
+ "keyField": "stock_inbound_id",
564
+ "publishOn": { "insert": true, "update": true, "delete": false }
565
+ }
566
+ ```
567
+
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`.
577
+
578
+ ---
579
+
580
+ ## Components (Lifecycle Hooks)
581
+
582
+ `components` binds CRUD lifecycle events to functions in local JavaScript
583
+ handler files. Added to a standard CRUD payload (one that has `tableName`).
584
+
585
+ ```json
586
+ {
587
+ "components": [
588
+ {
589
+ "properties": {
590
+ "filename": "components/supplier-hooks.js",
591
+ "methods": [
592
+ {
593
+ "name": "validateSupplierCode",
594
+ "events": "onBeforeInsert",
595
+ "params": [
596
+ { "value": "{requestData}" },
597
+ { "value": "{user_id}" }
598
+ ]
599
+ },
600
+ {
601
+ "name": "notifySlack",
602
+ "events": "onAfterInsert",
603
+ "params": [{ "value": "{newData}" }]
604
+ }
605
+ ]
606
+ }
607
+ }
608
+ ]
609
+ }
610
+ ```
611
+
612
+ | Property | Required | Notes |
613
+ |---|---|---|
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 |
615
+ | `components[].properties.methods` | Yes | List of method bindings |
616
+ | `methods[].name` | Yes | Function name exported from the handler file |
617
+ | `methods[].events` | Yes | Event hook (see table below) |
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 |
619
+
620
+ **Supported event hooks:**
621
+
622
+ | Event | Trigger |
623
+ |---|---|
624
+ | `onBeforeInsert`, `onAfterInsert` | `/create` endpoint |
625
+ | `onBeforeUpdate`, `onAfterUpdate` | `/update` endpoint |
626
+ | `onBeforeDelete`, `onAfterDelete` | `/delete` endpoint |
627
+ | `onBeforeCompositeInsert`, `onAfterCompositeInsert` | `/create-composite` endpoint |
628
+ | `onBeforeCompositeUpdate`, `onAfterCompositeUpdate` | `/update-composite` endpoint |
629
+ | `onBeforeAdjust`, `onAfterAdjust` | `/adjust` endpoint |
630
+ | `onBeforeWorkflow`, `onAfterWorkflow` | `/change-status` endpoint |
631
+
632
+ **Template variables for `params[].value`:**
633
+
634
+ | Variable | Value |
635
+ |---|---|
636
+ | `{tableName}` | Resource table name |
637
+ | `{requestData}` | Full request body |
638
+ | `{oldData}` | Data before operation (`update`, `delete`) |
639
+ | `{newData}` | Data after operation (`create`, `update`) |
640
+ | `{operation}` | Operation name: `insert` / `update` / `delete` |
641
+ | `{user_id}` | User ID from request context |
642
+ | `{timestamp}` | Execution timestamp |
643
+ | `{record_id}` | Primary key of the affected record |
644
+
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`):
649
+
650
+ ```javascript
651
+ async function validateSupplierCode(requestData, userId, services) {
652
+ const { db, logger, redis, kafka, cache } = services;
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 };
657
+ }
658
+ module.exports = { validateSupplierCode };
659
+ ```
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
+
670
+ A lifecycle hook is not a substitute for declared validation: keep rule checks
671
+ that `fieldValidation` can express in the payload, grounded with
672
+ `codegen_get_field_validation_catalog`, and reserve `components` for logic the
673
+ catalog genuinely cannot express (external calls, cross-table effects,
674
+ notifications, status locks).
675
+
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 |