@dforge-core/dforge-mcp 0.1.0-rc.1

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.
@@ -0,0 +1,939 @@
1
+ # Module Development Conventions
2
+
3
+ ## Overview
4
+
5
+ This document defines the conventions and standards for developing modules in the dForge platform.
6
+ Use the CRM sample module (`modules/crm/`) as a reference implementation.
7
+
8
+ ---
9
+
10
+ ## File Formatting
11
+
12
+ ### Indentation
13
+
14
+ **All project files use TABS for indentation:**
15
+
16
+ - **C# files** (`.cs`): Tabs, size 4
17
+ - **JSON files** (`.json`): Tabs, size 2
18
+ - **TypeScript/JavaScript** (`.ts`, `.js`): Tabs, size 4
19
+ - **Svelte files** (`.svelte`): Tabs, size 4
20
+ - **SQL files** (`.sql`): Tabs, size 4
21
+ - **Markdown files** (`.md`): Tabs
22
+
23
+ **Exceptions** (spaces only):
24
+ - `package.json`
25
+ - `tsconfig.json`
26
+ - `*.config.js` files
27
+
28
+ ### Line Endings
29
+
30
+ - Use LF (Unix-style) line endings for all files
31
+ - Ensure final newline at end of file
32
+ - Trim trailing whitespace
33
+
34
+ ---
35
+
36
+ ## Module Structure
37
+
38
+ ### Standard Module Layout
39
+
40
+ ```
41
+ /your-module/
42
+ ├── manifest.json # Module metadata + entity registry
43
+ ├── settings.json # Module settings (optional)
44
+ ├── /entities/ # Entity definitions (one per file)
45
+ │ ├── entity1.json
46
+ │ ├── entity2.json
47
+ │ └── entity3.json
48
+ ├── /ui/ # UI definitions
49
+ │ ├── data_views.json # Data view definitions
50
+ │ ├── menus.json # Menu definitions
51
+ │ ├── folders.json # Folder + entity access definitions
52
+ │ ├── actions.json # Action metadata (optional)
53
+ │ ├── reports.json # Report definitions (optional)
54
+ │ └── print_templates.json # Print template definitions (optional)
55
+ ├── /logic/ # Business logic
56
+ │ ├── /actions/ # DSL action scripts
57
+ │ │ ├── approve.dsl
58
+ │ │ └── send_invoice.dsl
59
+ │ ├── triggers.json # Event triggers (optional) — "when X happens, run action Y"
60
+ │ ├── webhooks.json # Webhook subscriptions (optional) — external HTTP notifications
61
+ │ └── jobs.json # Scheduled jobs (optional) — cron-driven action fires
62
+ ├── /seed-data/ # Seed data files (numbered for FK order)
63
+ │ ├── 01-skills.json
64
+ │ ├── 02-departments.json
65
+ │ └── 03-employees.json
66
+ ├── /security/ # Security definitions
67
+ │ └── roles.json
68
+ ├── /translations/ # Translations (optional)
69
+ │ ├── en-US.json
70
+ │ └── de-DE.json
71
+ ├── /print_templates/ # HTML/CSS print templates (optional)
72
+ │ ├── invoice.html
73
+ │ └── invoice.css
74
+ └── /files/ # Module static files (optional) — README, docs
75
+ └── README.md
76
+ ```
77
+
78
+ ---
79
+
80
+ ## JSON File Conventions
81
+
82
+ ### 1. Manifest (`manifest.json`)
83
+
84
+ ```json
85
+ {
86
+ "packageFormat": 1,
87
+ "moduleId": "10000000-0000-0000-0000-000000000002",
88
+ "code": "hr",
89
+ "version": "0.0.1",
90
+ "dbSchemaVersion": "0.0.1",
91
+ "displayName": "Human Resources",
92
+ "description": "HR management module",
93
+ "author": { "name": "dForge" },
94
+ "license": "MIT",
95
+ "entities": {
96
+ "department": "./entities/department.json",
97
+ "employee": "./entities/employee.json"
98
+ }
99
+ }
100
+ ```
101
+
102
+ **Rules:**
103
+ - `code` becomes the DB schema name and folder path
104
+ - `entities` maps entity codes to file paths (relative to module root)
105
+ - Omit `"system": true` for regular modules (defaults to false)
106
+ - Omit `schemaName` to use `code` as the schema name
107
+
108
+ ### 1b. Module Extensions
109
+
110
+ Modules can add columns to entities owned by other modules using **extension entity files** — entity JSON files with an `extends` property. Extensions support both virtual columns (Reference, Set, Formula — metadata only) and physical columns (actual DB columns stored in a 1:1 extension table). This eliminates circular dependencies: the extending module declares the cross-module columns instead of the target entity.
111
+
112
+ Extension entity files are referenced from the manifest's `entities` map using dotted keys as a naming convention:
113
+
114
+ ```json
115
+ {
116
+ "dependencies": {
117
+ "crm": ">=0.0.1",
118
+ "fin": ">=0.0.1"
119
+ },
120
+ "entities": {
121
+ "fin.invoice": "entities/fin.invoice.json",
122
+ "fin.invoice_line": "entities/fin.invoice_line.json",
123
+ "crm.quote": "entities/crm.quote.json"
124
+ }
125
+ }
126
+ ```
127
+
128
+ The entity file uses the same `fields` schema as regular entities, plus an `extends` property:
129
+
130
+ ```json
131
+ {
132
+ "extends": "fin.invoice",
133
+ "dbObject": "invoice",
134
+ "description": "CRM extensions for Invoice",
135
+ "fields": {
136
+ "customer_id": {
137
+ "dbDatatype": "int8",
138
+ "isNullable": true,
139
+ "flags": "EM",
140
+ "orderNum": 34,
141
+ "description": "Customer FK"
142
+ },
143
+ "customer": {
144
+ "columnType": "R",
145
+ "fieldTypeCd": "lookup",
146
+ "flags": "VEM",
147
+ "orderNum": 35,
148
+ "description": "Customer",
149
+ "link": {
150
+ "entity": "crm.account",
151
+ "thisKey": "customer_id",
152
+ "otherKey": "account_id"
153
+ }
154
+ }
155
+ }
156
+ }
157
+ ```
158
+
159
+ In this example, `customer_id` is a physical column (has `dbDatatype`) stored in `"crm_fin"."invoice_ext"`, while `customer` is a virtual Reference column (metadata only). The FK+Reference two-column pattern applies to extensions the same way as regular entities.
160
+
161
+ **Physical extension columns** are stored in a dedicated 1:1 extension table created in the bridge module's schema (e.g., `"crm_fin"."invoice_ext"`), not in the target entity's table. The platform handles this transparently — queries automatically LEFT JOIN the ext table, and writes split between the main table and ext table.
162
+
163
+ **Rules:**
164
+ - Entity files with `extends` are treated as extensions, not new entities
165
+ - The `extends` value must be a qualified entity code: `"module.entity"` (e.g., `"fin.invoice"`)
166
+ - The target module must be declared as a dependency
167
+ - Extension columns **must** use the `link` object (no fallback to `references` section)
168
+ - `dbObject` is optional — used for documentation and validation
169
+ - Physical columns (those with `dbDatatype`) are created in a 1:1 extension table: `{bridge_schema}.{entity_cd}_ext`
170
+ - The ext table PK is an FK to the target entity's PK — enforcing 1:1 relationship
171
+ - Virtual columns (`columnType: "R"`, `"S"`, or `"F"`) are metadata only — no ext table needed
172
+ - The `storage_table` metadata column tracks which ext table stores each physical extension column (set automatically by the installer)
173
+ - Extensions are processed after the module's own entities are registered
174
+ - Install order matters: the target module must be installed before the extending module
175
+
176
+ **File naming convention:** Use dotted names matching the target entity (`entities/fin.invoice.json`). This makes it immediately obvious in the file system which files are extensions:
177
+
178
+ ```
179
+ entities/
180
+ ├── fin.invoice.json # extends fin.invoice
181
+ ├── fin.invoice_line.json # extends fin.invoice_line
182
+ └── crm.quote.json # extends crm.quote
183
+ ```
184
+
185
+ **How ext tables work at runtime:**
186
+
187
+ ```sql
188
+ -- Query builder automatically adds LEFT JOIN for ext table columns:
189
+ SELECT t0."invoice_id", t0."invoice_no", ext0."customer_id"
190
+ FROM "fin"."invoice" t0
191
+ LEFT OUTER JOIN "crm_fin"."invoice_ext" ext0
192
+ ON ext0."invoice_id" = t0."invoice_id"
193
+
194
+ -- Writes split automatically:
195
+ -- 1. Main table INSERT/UPDATE (columns without storage_table)
196
+ -- 2. Ext table UPSERT (INSERT ON CONFLICT DO UPDATE) for ext columns
197
+ ```
198
+
199
+ **Uninstall** is clean: `DROP SCHEMA CASCADE` removes the ext tables, and extension column metadata rows are cleaned by matching `storage_table LIKE '{schema}%'`.
200
+
201
+ **Bridge module pattern (recommended):**
202
+ Rather than having core modules extend each other directly, create small bridge modules that own the integration. For example, instead of CRM extending FIN entities, create a `crm-fin` module that depends on both CRM and FIN and declares all the cross-module extensions, actions, and views.
203
+
204
+ ```
205
+ CRM (standalone) ──┐
206
+ ├── crm-fin (bridge: extensions + actions + views)
207
+ FIN (standalone) ──┘
208
+ ```
209
+
210
+ Benefits:
211
+ - Core modules remain fully independent (CRM works without FIN and vice versa)
212
+ - Integration is opt-in: only install the bridge when both modules are present
213
+ - Third-party developers can create bridges between any two modules
214
+ - Clean uninstall — DROP SCHEMA removes ext tables, no ALTER TABLE on other modules
215
+
216
+ Bridge modules typically have no entities of their own — just extensions, `actions`, and optionally `views`.
217
+
218
+ Multiple bridge modules can extend the same entity (e.g., `crm-fin` and `hr-fin` both extend `fin.invoice`). Each creates its own ext table; the query builder adds multiple LEFT JOINs on PK columns.
219
+
220
+ Reference implementations: `modules/crm-fin/`, `wms-fin/`
221
+
222
+ **Bridge menu merge constraints.** When a bridge module declares a `menus.json` with a menu name that already exists (owned by a core module), its items are merged into the existing menu under matching sections. Two constraints apply:
223
+
224
+ 1. **Bridge menu items must be leaves** (`itemType` of `V`, `R`, or `D`). Do not insert sub-folders (`itemType: F`) from a bridge. On reinstall the cleaner identifies bridge-owned items via `data_view_id` / `report_id` FKs back to the bridge's module_id — folder rows have neither FK, so they cannot be traced back and would accumulate on every reinstall.
225
+
226
+ 2. **Bridge menu items must reference the bridge's own views/reports**, not the host module's. The platform populates the report/view lookups from all installed modules so technically a bridge `menus.json` can reference any code, but reinstall cleanup only matches by the bridge's own module_id. Referencing a host-module report means the stale menu_item survives reinstall and the new INSERT then fails against the `ux_menu_item_report` / `ux_menu_item_view` partial unique indexes. If a bridge wants to expose a host-owned resource under its own menu slot, duplicate the definition into the bridge's `reports.json` / `data_views.json` so ownership stays within the bridge.
227
+
228
+ **When to use extensions vs. regular references:**
229
+ - Use extensions (via a bridge module) when two independent modules need cross-module lookups or actions
230
+ - Use regular `link` references for one-way dependencies where the target entity already exists
231
+
232
+ ### 2. Data View Definitions (`ui/data_views.json`)
233
+
234
+ **CRITICAL: Every view MUST use a `dataSources` array. The client will not render views without it.**
235
+
236
+ **Simple grid view:**
237
+ ```json
238
+ {
239
+ "positions": {
240
+ "viewType": "grid",
241
+ "description": "Positions",
242
+ "dataSources": [
243
+ {
244
+ "entityCode": "position",
245
+ "level": 0,
246
+ "columns": [
247
+ "position_code",
248
+ { "column_cd": "position_title", "width": 200 },
249
+ "department",
250
+ "is_active"
251
+ ]
252
+ }
253
+ ]
254
+ }
255
+ }
256
+ ```
257
+
258
+ **Master-detail view (parent + child tabs):**
259
+ ```json
260
+ {
261
+ "employee_detail": {
262
+ "viewType": "master-detail",
263
+ "description": "Employee Detail",
264
+ "dataSources": [
265
+ {
266
+ "entityCode": "employee",
267
+ "level": 0,
268
+ "columns": ["employee_code", "first_name", "last_name", "department"]
269
+ },
270
+ {
271
+ "entityCode": "leave_request",
272
+ "level": 1,
273
+ "parentSetField": "leave_requests",
274
+ "label": "Leave Requests"
275
+ },
276
+ {
277
+ "entityCode": "document",
278
+ "level": 1,
279
+ "parentSetField": "documents",
280
+ "label": "Documents"
281
+ }
282
+ ]
283
+ }
284
+ }
285
+ ```
286
+
287
+ **Rules:**
288
+ - Each view is a dictionary entry keyed by view code
289
+ - `dataSources` is a **required array** — never put `entityCode`/`columns` at the root level
290
+ - Level 0 = primary data source (one per view)
291
+ - Level 1 = child data sources (for master-detail views)
292
+ - `parentSetField` on level 1 sources must match a Set column field name on the parent entity
293
+ - Columns can be strings (field code) or objects (`{ "column_cd": "name", "width": 200 }`)
294
+
295
+ **Common Mistakes:**
296
+ - ❌ `"entityCode": "employee"` at view root level → ✅ Put inside `dataSources[0]`
297
+ - ❌ `"columns": [...]` at view root level → ✅ Put inside `dataSources[0]`
298
+ - ❌ `"isDefault": true` at view root level → ✅ Not a recognized property; remove it
299
+
300
+ ### 3. Menu Definitions (`ui/menus.json`)
301
+
302
+ **Menus use nested dictionaries (not arrays):**
303
+
304
+ ```json
305
+ {
306
+ "hr_menu": {
307
+ "description": "Human Resources",
308
+ "items": {
309
+ "organization": {
310
+ "orderNum": 1,
311
+ "description": "Organization",
312
+ "children": {
313
+ "departments": {
314
+ "itemType": "V",
315
+ "dataViewCode": "departments",
316
+ "orderNum": 1,
317
+ "description": "Departments"
318
+ },
319
+ "positions": {
320
+ "itemType": "V",
321
+ "dataViewCode": "positions",
322
+ "orderNum": 2,
323
+ "description": "Positions"
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ }
330
+ ```
331
+
332
+ **Rules:**
333
+ - Top-level key is the menu code (e.g. `hr_menu`)
334
+ - `items` is a **dictionary** of sections (not an array)
335
+ - Section children are also a **dictionary** under `children`
336
+ - Omit `itemType` for folder/section items (parent nodes)
337
+ - Only leaf items have `"itemType": "V"` with `dataViewCode`
338
+ - Use `dataViewCode` (not `viewCode`) to reference data views
339
+
340
+ **Common Mistakes:**
341
+ - ❌ `"items": [{ "code": "...", "label": "..." }]` (array format) → ✅ Use dictionary format
342
+ - ❌ `"viewCode": "departments"` → ✅ Use `"dataViewCode": "departments"`
343
+ - ❌ `"itemType": null` on sections → ✅ Omit `itemType` entirely
344
+
345
+ ### 4. Folder Definitions (`ui/folders.json`)
346
+
347
+ **The whole `folders.json` file is the module's root folder.** A module gets
348
+ exactly one root folder (named after the module code automatically), and any
349
+ subfolders nest under a `children` object. Folder paths and parent/child
350
+ relationships are derived from the JSON tree structure — there is no
351
+ `folderPath` or `parentCode` field to write.
352
+
353
+ **Minimal example** (the `crm` module):
354
+
355
+ ```json
356
+ {
357
+ "label": "Sales CRM",
358
+ "description": "Customer relationship management and sales pipeline",
359
+ "color": "#2196F3",
360
+ "entities": {
361
+ "account": { "viewName": "default", "quickAdd": true },
362
+ "contact": { "viewName": "default", "quickAdd": true },
363
+ "lead": { "viewName": "default", "quickAdd": true },
364
+ "opportunity": { "viewName": "default", "quickAdd": true }
365
+ }
366
+ }
367
+ ```
368
+
369
+ That's the entire file. Modules with no subfolders (the 80% case) end here.
370
+
371
+ **Nested example** (the `wms` module — root + three regional warehouses):
372
+
373
+ ```json
374
+ {
375
+ "label": "Warehouse Management",
376
+ "description": "Inventory, purchasing, and warehouse operations",
377
+ "color": "#FF9800",
378
+ "entities": {
379
+ "warehouse": { "viewName": "default", "quickAdd": true },
380
+ "product": { "viewName": "default", "quickAdd": true },
381
+ "stock": { "viewName": "default", "quickAdd": true }
382
+ },
383
+ "children": {
384
+ "central": {
385
+ "label": "Central Warehouse",
386
+ "description": "Main distribution center",
387
+ "color": "#2196F3",
388
+ "inheritSecurity": true,
389
+ "entities": {
390
+ "stock": {
391
+ "viewName": "default",
392
+ "rowFilter": { "c": "warehouse_id", "o": "eq", "v": 2001 }
393
+ }
394
+ }
395
+ },
396
+ "east": {
397
+ "label": "East Warehouse",
398
+ "color": "#4CAF50",
399
+ "inheritSecurity": true,
400
+ "entities": { /* with rowFilter for east */ }
401
+ },
402
+ "west": {
403
+ "label": "West Warehouse",
404
+ "color": "#9C27B0",
405
+ "inheritSecurity": true,
406
+ "entities": { /* with rowFilter for west */ }
407
+ }
408
+ }
409
+ }
410
+ ```
411
+
412
+ The install pipeline derives:
413
+
414
+ | Source | `folder_path` in DB | `folder_code` (synthesized) |
415
+ |---|---|---|
416
+ | Root (the file itself) | `wms` | `wms` |
417
+ | `children.central` | `wms/central` | `wms_central` |
418
+ | `children.east` | `wms/east` | `wms_east` |
419
+ | `children.west` | `wms/west` | `wms_west` |
420
+
421
+ The synthesized flat code is the module code joined with each child key by
422
+ underscores; the DB path is the module code joined with each child key by
423
+ slashes.
424
+
425
+ **Rules:**
426
+
427
+ - **The whole file is the root folder.** Top-level fields (`label`, `description`, `color`, `entities`) belong to the root.
428
+ - **Subfolders nest under `children`** (a dictionary keyed by path segment).
429
+ - **Each child key is a single path segment** — lowercase letters, digits, dashes, and underscores only, no slashes. Pattern: `^[a-z0-9][a-z0-9_-]*$`. Slashes are forbidden in keys because deeper trees use `children` recursively, not slashes in keys.
430
+ - **`entities` is a dictionary** mapping entity codes to settings objects. Each entity has `viewName` (string or null), `quickAdd` (boolean), and an optional `rowFilter`.
431
+ - **`inheritSecurity`** is allowed only on child folders. The root has no parent to inherit from, so the field is rejected at the root level with a clear error.
432
+ - **Children can have their own children**, recursively, for arbitrary tree depth.
433
+
434
+ **Forbidden fields (migration traps):**
435
+
436
+ - ❌ `folderPath` anywhere in the file. The path is derived; the field is rejected with an error pointing at `modules/<code>/ui/folders.json`.
437
+ - ❌ `parentCode` anywhere in the file. Parent/child relationships are expressed by nesting under `children`; the field is rejected with the same kind of error.
438
+ - ❌ `inheritSecurity` on the root.
439
+
440
+ **Common Mistakes:**
441
+
442
+ - ❌ `"entities": ["department", "employee"]` (array format) → ✅ Use dictionary with objects.
443
+ - ❌ `"quickAdd": ["employee"]` (separate array) → ✅ Set `quickAdd: true` per entity.
444
+ - ❌ Mixing the old flat-dictionary format with `parentCode` and `folderPath` fields → ✅ Migrate to the tree shape; the install pipeline will tell you exactly which field to remove.
445
+ - ❌ Using slashes in child keys (`"regions/east"`) → ✅ Nest with `children: { "regions": { "children": { "east": {} } } }`.
446
+
447
+ #### Why the format looks like this
448
+
449
+ The frontend routes folder URLs as `/{folderPath}/v/{viewSlug}` with no
450
+ module segment. If two modules' folders shared a `folder_path`, the sidebar
451
+ would render them indistinguishably even though the database stores them as
452
+ separate rows scoped per module. The previous file format encoded paths and
453
+ parent/child relationships as explicit fields (`folderPath`, `parentCode`)
454
+ that module authors could easily get wrong — and a scaffold-from-template
455
+ flow could silently produce a violating module by copying those fields
456
+ verbatim from the source.
457
+
458
+ The current tree-shape format eliminates the entire class of bug:
459
+
460
+ - The root path is **always** the module code (it's not even a field).
461
+ - Subfolder paths are **derived from nesting**, so they can't disagree with the parent/child relationship.
462
+ - The whole file is module-scoped by construction — paths under one module physically cannot collide with another module's paths because they all start with their own module's code.
463
+
464
+ The DB still has a `UNIQUE INDEX` on `(module_id, folder_path)` as
465
+ defense-in-depth, and `FolderTreeFlattener` validates the file format
466
+ before the install pipeline touches the database.
467
+
468
+ ### 5. Security Roles (`security/roles.json`)
469
+
470
+ ```json
471
+ {
472
+ "hr.admin": {
473
+ "description": "HR Administrator — full access",
474
+ "rights": {
475
+ "department": "SIUDC",
476
+ "employee": "SIUDC",
477
+ "leave_request": "SIUDC"
478
+ }
479
+ },
480
+ "hr.viewer": {
481
+ "description": "HR Viewer — read-only",
482
+ "rights": {
483
+ "department": "S",
484
+ "employee": "S",
485
+ "leave_request": "S"
486
+ }
487
+ }
488
+ }
489
+ ```
490
+
491
+ **Rules:**
492
+ - Key is the role code (convention: `module.role-name`)
493
+ - **Property name is `"rights"` (NOT `"entityRights"`)** — this maps to `RoleDef.Rights`
494
+ - Each entry in `rights` maps entity code → rights string
495
+ - Rights characters: `S` (Select), `I` (Insert), `U` (Update), `D` (Delete), `C` (Clone)
496
+ - Every entity should appear in every role (even if only `"S"` for read-only)
497
+
498
+ **Common Mistakes:**
499
+ - ❌ `"entityRights": { ... }` → ✅ Use `"rights": { ... }`
500
+ - ❌ Omitting entities from a role → Entity will be inaccessible to that role
501
+
502
+ ### 6. Seed Data (`seed-data/*.json`)
503
+
504
+ **Number files to ensure FK dependency order:**
505
+
506
+ ```
507
+ seed-data/
508
+ ├── 01-skills.json # No FKs
509
+ ├── 02-departments.json # No FKs
510
+ ├── 03-positions.json # FK → department
511
+ ├── 04-employees.json # FK → department, position
512
+ └── 05-leave_requests.json # FK → employee
513
+ ```
514
+
515
+ **File format:**
516
+ ```json
517
+ {
518
+ "entityCode": "department",
519
+ "records": [
520
+ {
521
+ "department_id": "a0030000-0000-0000-0000-000000000001",
522
+ "department_code": "EXEC",
523
+ "department_name": "Executive / Leadership",
524
+ "is_active": true
525
+ }
526
+ ]
527
+ }
528
+ ```
529
+
530
+ **Rules:**
531
+ - Files are loaded alphabetically — use numbered prefixes (01-, 02-, etc.)
532
+ - Parent tables must come before child tables (FK dependency order)
533
+ - Include explicit PK values (UUIDs) so child records can reference them
534
+ - Use a stable UUID scheme: `a00X0000-0000-0000-0000-00000000000Y` where X=entity type, Y=record
535
+ - Inserts use `ON CONFLICT DO NOTHING` (idempotent)
536
+ - UUIDs and dates in string format are auto-converted by the seed runner
537
+ - Omit auto-generated fields (`created_date`, `last_updated`) — they use DB defaults
538
+ - Omit cross-module FK fields (e.g., `owner_id` referencing `user` table)
539
+
540
+ ### 7. Scheduled Jobs (`logic/jobs.json`)
541
+
542
+ Cron-driven action fires. Each entry pairs an existing action (declared in `ui/actions.json`) with a 5-field cron expression; the `dForge.Scheduler` worker fires it on schedule. Full reference: [module-package-format.md → jobs.json](./module-package-format.md#jobsjson).
543
+
544
+ ```json
545
+ {
546
+ "jobs": [
547
+ {
548
+ "code": "tick",
549
+ "description": "Fires log_tick every minute",
550
+ "action": "log_tick",
551
+ "schedule": "* * * * *",
552
+ "timeout": 30,
553
+ "class": "standard"
554
+ }
555
+ ]
556
+ }
557
+ ```
558
+
559
+ **Rules:**
560
+ - `code` — unique within the module, `[a-z][a-z0-9_]*`
561
+ - `action` — must be an action declared in this module's `ui/actions.json`; cross-module references are rejected
562
+ - `schedule` — five fields, minute granularity (sub-minute schedules are rejected at install)
563
+ - `timeout` — required, in seconds, range `(0, 3600]`
564
+ - `timeout > 300` requires `"class": "long_running"` (the standard pool is capped at 300s)
565
+ - Referenced action must NOT use `[field]` or `for x in records` — scheduled fires have no record context. Install fails fast if it does. Use `query()` / `insert()` for table-level work.
566
+ - Max 50 jobs per module
567
+ - Optional `timeZone` (IANA name) overrides `auth.tenant.time_zone` for that job
568
+ - Use `paused: true` to freeze a job during incidents — admin "Run now" still works while the cron path is silent
569
+
570
+ **Reference:** `modules/chore/` — single-job smoke-test module.
571
+
572
+ ### 8. Translations (`translations/en-US.json`)
573
+
574
+ ```json
575
+ {
576
+ "entities": {
577
+ "department": {
578
+ "label": "Department",
579
+ "desc": "Company departments",
580
+ "fields": {
581
+ "department_id": { "label": "Department ID" },
582
+ "department_name": { "label": "Department Name" }
583
+ }
584
+ }
585
+ },
586
+ "folders": {
587
+ "hr": { "label": "Human Resources" }
588
+ },
589
+ "views": {
590
+ "departments": { "label": "Departments" },
591
+ "employees": { "label": "All Employees" }
592
+ },
593
+ "menus": {
594
+ "hr_menu": { "label": "Human Resources" }
595
+ },
596
+ "roles": {
597
+ "hr.admin": { "label": "HR Administrator" },
598
+ "hr.viewer": { "label": "HR Viewer" }
599
+ }
600
+ }
601
+ ```
602
+
603
+ ---
604
+
605
+ ## Entity Definitions (`entities/*.json`)
606
+
607
+ ### Complete Entity Example
608
+
609
+ ```json
610
+ {
611
+ "description": "Employees",
612
+ "dbObject": "employee",
613
+ "toString": "{first_name} {last_name}",
614
+ "fields": {
615
+ "employee_id": {
616
+ "dbDatatype": "uuid",
617
+ "isPk": true,
618
+ "isIdentity": true,
619
+ "isNullable": false,
620
+ "orderNum": 10,
621
+ "description": "Employee ID"
622
+ },
623
+ "employee_code": {
624
+ "dbDatatype": "varchar",
625
+ "fieldTypeCd": "text",
626
+ "flags": "VEM",
627
+ "isNullable": false,
628
+ "maxLen": 20,
629
+ "orderNum": 20,
630
+ "description": "Employee Code"
631
+ },
632
+ "department_id": {
633
+ "dbDatatype": "uuid",
634
+ "flags": "EM",
635
+ "orderNum": 30,
636
+ "description": "Department ID"
637
+ },
638
+ "department": {
639
+ "columnType": "R",
640
+ "fieldTypeCd": "lookup",
641
+ "flags": "VEM",
642
+ "orderNum": 35,
643
+ "description": "Department",
644
+ "link": {
645
+ "entity": "department",
646
+ "thisKey": "department_id",
647
+ "otherKey": "department_id"
648
+ }
649
+ },
650
+ "employment_status": {
651
+ "dbDatatype": "varchar",
652
+ "fieldTypeCd": "dropdown",
653
+ "flags": "VEM",
654
+ "isNullable": false,
655
+ "maxLen": 50,
656
+ "formula": "'Active'",
657
+ "orderNum": 40,
658
+ "description": "Status",
659
+ "params": {
660
+ "options": ["Active", "On Leave", "Suspended", "Terminated"]
661
+ }
662
+ },
663
+ "leave_requests": {
664
+ "columnType": "S",
665
+ "fieldTypeCd": "grid",
666
+ "flags": "VEM",
667
+ "orderNum": 100,
668
+ "description": "Leave Requests",
669
+ "link": {
670
+ "entity": "leave_request",
671
+ "thisKey": "employee_id",
672
+ "otherKey": "employee_id"
673
+ }
674
+ }
675
+ },
676
+ "constraints": {
677
+ "UQ_employee_code": {
678
+ "type": "unique",
679
+ "fields": ["employee_code"],
680
+ "message": "Employee code must be unique"
681
+ },
682
+ "chk_salary_positive": {
683
+ "type": "check",
684
+ "expression": "salary > 0",
685
+ "message": "Salary must be positive"
686
+ }
687
+ },
688
+ "references": {
689
+ "FK_Employee_Department": {
690
+ "from": { "field": "department_id" },
691
+ "to": { "entity": "department", "field": "department_id" }
692
+ }
693
+ }
694
+ }
695
+ ```
696
+
697
+ ---
698
+
699
+ ## FK+Reference Two-Column Pattern
700
+
701
+ For every foreign key relationship, create TWO columns:
702
+
703
+ ### 1. Hidden FK Column (Database)
704
+ ```json
705
+ "department_id": {
706
+ "dbDatatype": "uuid",
707
+ "flags": "EM",
708
+ "orderNum": 30,
709
+ "description": "Department ID"
710
+ }
711
+ ```
712
+
713
+ ### 2. Visible Reference Column (UI)
714
+ ```json
715
+ "department": {
716
+ "columnType": "R",
717
+ "fieldTypeCd": "lookup",
718
+ "flags": "VEM",
719
+ "orderNum": 35,
720
+ "description": "Department",
721
+ "link": {
722
+ "entity": "department",
723
+ "thisKey": "department_id",
724
+ "otherKey": "department_id"
725
+ }
726
+ }
727
+ ```
728
+
729
+ ### 3. Declare Foreign Key
730
+ ```json
731
+ "references": {
732
+ "FK_Employee_Department": {
733
+ "from": { "field": "department_id" },
734
+ "to": { "entity": "department", "field": "department_id" }
735
+ }
736
+ }
737
+ ```
738
+
739
+ **Rules:**
740
+ - Reference columns use `"link"` (not `"params"`) for entity binding
741
+ - `link.entity` = target entity code, `link.thisKey` = local FK field, `link.otherKey` = remote PK field
742
+ - `params` is used for other purposes (e.g., dropdown `options`)
743
+ - Hidden FK column: `flags: "EM"` (no `V` = hidden from UI)
744
+ - Visible reference column: `flags: "VEM"`, `columnType: "R"`
745
+
746
+ ---
747
+
748
+ ## Set Columns (1:N Relationships)
749
+
750
+ Declare set columns on parent entities for child collections:
751
+
752
+ ```json
753
+ "leave_requests": {
754
+ "columnType": "S",
755
+ "fieldTypeCd": "grid",
756
+ "flags": "VEM",
757
+ "orderNum": 100,
758
+ "description": "Leave Requests",
759
+ "link": {
760
+ "entity": "leave_request",
761
+ "thisKey": "employee_id",
762
+ "otherKey": "employee_id"
763
+ }
764
+ }
765
+ ```
766
+
767
+ **Rules:**
768
+ - Set columns use `"link"` (same as reference columns)
769
+ - `link.entity` = child entity, `link.thisKey` = parent PK, `link.otherKey` = child FK
770
+ - The set field name is used as `parentSetField` in master-detail data views
771
+
772
+ ---
773
+
774
+ ## Field Type Reference
775
+
776
+ ### Valid Field Types
777
+
778
+ | Code | Description | Column Type |
779
+ |------|-------------|-------------|
780
+ | `text` | Single-line text | D (Data) |
781
+ | `email` | Email address | D |
782
+ | `phone` | Phone number | D |
783
+ | `url` | URL / web address | D |
784
+ | `textarea` | Multi-line text | D |
785
+ | `code` | Code editor (monospace) | D |
786
+ | `richtext` | Rich text editor | D |
787
+ | `number` | Numeric input | D |
788
+ | `currency` | Currency with precision | D |
789
+ | `percent` | Percentage value | D |
790
+ | `checkbox` | Boolean checkbox | D |
791
+ | `date` | Date | D |
792
+ | `datetime` | Date and time | D |
793
+ | `time` | Time | D |
794
+ | `dropdown` | Dropdown select | D |
795
+ | `flags` | Multi-select checkboxes (bitwise flags) | D |
796
+ | `tags` | Tag / chip input | D |
797
+ | `user` | User selector | D |
798
+ | `entitylink` | Entity record link | D |
799
+ | `json` | JSON editor | D |
800
+ | `hidden` | Hidden field | D |
801
+ | `color` | Color picker | D |
802
+ | `image` | Image upload | D |
803
+ | `file` | File upload | D |
804
+ | `lookup` | Reference lookup | R (Reference) |
805
+ | `grid` | Detail grid | S (Set) |
806
+
807
+ **Common Mistakes:**
808
+ - ❌ `"integer"` → ✅ Use `"number"`
809
+ - ❌ `"datePicker"` → ✅ Use `"date"`
810
+ - ❌ `"autocomplete"` → ✅ Use `"lookup"`
811
+
812
+ ### Dropdown Options (`params.options`)
813
+
814
+ For `dropdown` fields, define available options in `params.options`:
815
+
816
+ **Simple format** — value equals label:
817
+ ```json
818
+ "status": {
819
+ "dbDatatype": "varchar",
820
+ "fieldTypeCd": "dropdown",
821
+ "flags": "VEM",
822
+ "maxLen": 50,
823
+ "params": {
824
+ "options": ["New", "In Progress", "Done"]
825
+ }
826
+ }
827
+ ```
828
+
829
+ **Rich format** — separate value/label with optional icon and color:
830
+ ```json
831
+ "priority": {
832
+ "dbDatatype": "varchar",
833
+ "fieldTypeCd": "dropdown",
834
+ "flags": "VEM",
835
+ "maxLen": 20,
836
+ "params": {
837
+ "options": [
838
+ { "value": "low", "label": "Low", "icon": "🟢", "color": "#e8f5e9" },
839
+ { "value": "medium", "label": "Medium", "icon": "🟡", "color": "#fff3e0" },
840
+ { "value": "high", "label": "High", "icon": "🔴", "color": "#ffebee" }
841
+ ]
842
+ }
843
+ }
844
+ ```
845
+
846
+ **Rich option properties:**
847
+ | Property | Required | Description |
848
+ |----------|----------|-------------|
849
+ | `value` | Yes | Stored value (must fit `maxLen`) |
850
+ | `label` | Yes | Display label |
851
+ | `icon` | No | Emoji or icon string shown before label |
852
+ | `color` | No | Background color for badge display (hex, e.g. `"#e8f5e9"`) |
853
+
854
+ Both formats can be mixed in the same array, but this is not recommended.
855
+
856
+ ---
857
+
858
+ ## Version Management
859
+
860
+ ### Semantic Versioning
861
+
862
+ Use semantic versioning: `MAJOR.MINOR.PATCH`
863
+
864
+ - **MAJOR**: Breaking changes to schema or entity definitions
865
+ - **MINOR**: New entities or backward-compatible features
866
+ - **PATCH**: Bug fixes, documentation updates
867
+
868
+ ### Two Version Numbers
869
+
870
+ 1. **`version`**: Overall module version (includes all changes)
871
+ 2. **`dbSchemaVersion`**: Database schema version (only bumped for schema changes)
872
+
873
+ ---
874
+
875
+ ## Installation & Reinstallation
876
+
877
+ ### CLI Commands
878
+
879
+ ```bash
880
+ # Via Docker (from /docker directory)
881
+ docker/cli.sh module install --code <tenant> --path /modules/<module>
882
+ docker/setup-tenant.sh <code> <name> [email] [--erp]
883
+ docker/install-modules.sh <code> --erp
884
+
885
+ # Local CLI (from /server directory)
886
+ dotnet run --project src/dForge.Cli -- module install --code <tenant> --path /path/to/module
887
+
888
+ # Force reinstall (cleans metadata and re-registers)
889
+ dotnet run --project src/dForge.Cli -- module install --code <tenant> --path /path/to/module --force
890
+ ```
891
+
892
+ ### What Happens During Install
893
+
894
+ 1. **DDL**: Schema + tables created (`CREATE TABLE IF NOT EXISTS`)
895
+ 2. **Seed data**: Records inserted (`INSERT ... ON CONFLICT DO NOTHING`)
896
+ 3. **Entities**: Registered in `dForge.entity` + `dForge.entity_column`
897
+ 4. **Security**: `sec_object` + `module_role` + `module_role_rights` + `user_role`
898
+ 5. **Data views**: Registered in `dForge.data_view`
899
+ 6. **Menus**: `dForge.menu` + `dForge.menu_item` + `dForge.folder` + `dForge.folder_entity` + `dForge.folder_menu`
900
+
901
+ ### Force Reinstall
902
+
903
+ With `--force`, the metadata cleaner runs first:
904
+ - Deletes: `folder_entity` → `folder_menu` → `folder` → `menu_item` → `menu` → `data_view` → `module_role_rights` → `user_role` → `module_role` → `sec_object` → `entity_column` → `entity`
905
+ - Then re-registers everything fresh
906
+
907
+ ---
908
+
909
+ ## Summary Checklist
910
+
911
+ When creating a module, ensure:
912
+
913
+ - [ ] Uses TABS for indentation (JSON: 2, Code: 4)
914
+ - [ ] `manifest.json` includes `entities` registry mapping codes to file paths
915
+ - [ ] Entity files use `"link"` (not `"params"`) for reference/set column bindings
916
+ - [ ] All `fieldTypeCd` values are valid — see [Field Types](../data-model/field-types.md) (use `number`, not `integer`)
917
+ - [ ] FK+Reference two-column pattern used for all foreign keys
918
+ - [ ] `data_views.json` uses `dataSources` array (not root-level `entityCode`)
919
+ - [ ] `menus.json` uses nested dictionaries with `children` (not arrays)
920
+ - [ ] `menus.json` leaf items use `dataViewCode` (not `viewCode`)
921
+ - [ ] `folders.json` uses entity dictionary with `{ viewName, quickAdd }` objects
922
+ - [ ] `roles.json` uses `"rights"` property (not `"entityRights"`)
923
+ - [ ] Seed data files are numbered for FK dependency order (01-, 02-, etc.)
924
+ - [ ] Seed data includes explicit PK UUIDs for cross-entity references
925
+ - [ ] `translations/en-US.json` covers entities, fields, views, menus, folders, roles
926
+ - [ ] Constraints have clear user-facing `message` values
927
+ - [ ] Check constraint `expression` uses standard SQL subset (test in PostgreSQL first)
928
+ - [ ] Number sequences declared as `numberSequence` on entity definitions (auto-fills on INSERT, never manual counting)
929
+ - [ ] Print templates defined in `ui/print_templates.json` with HTML files in `print_templates/`
930
+ - [ ] Scheduled jobs (if any) declared in `logic/jobs.json` with 5-field cron, explicit `timeout`, and `class: "long_running"` for any `timeout > 300`
931
+
932
+ ---
933
+
934
+ ## Reference Implementations
935
+
936
+ - **CRM module**: `modules/crm/` — complete example with 9 entities
937
+ - **HR module**: `modules/hr/` — complete example with 10 entities
938
+ - **Chore module**: `modules/chore/` — minimal `logic/jobs.json` reference (one cron-fired action)
939
+ - **System modules**: `server/database/system-modules/admin/` and `metadata/`