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,618 +1,621 @@
1
- # Reference: Design-to-SDF Heuristics
2
-
3
- > **Procedure, not a catalog dump.** This file is a classification procedure that
4
- > produces a *draft* SDF. It does not replace `codegen_get_dbschema_catalog`:
5
- > every draft is grounded against the catalog for valid options and MUST pass
6
- > `codegen_dbschema_validate` before any DDL is generated. Inferences marked
7
- > "confirm" are guesses — surface them to the user, never commit silently.
8
-
9
- Use when the goal is to derive an SDF (Schema Definition File) table structure
10
- from an external source. Exactly 5 source kinds are in scope — anything else
11
- is out of scope and must be refused rather than guessed at:
12
-
13
- | Source kind | Examples | Path |
14
- |---|---|---|
15
- | HTML | a UI mockup/template file | Inference path — Step 0-5 below |
16
- | Image | screenshot, photo, pasted clipboard image (jpg/png) | Inference path — Step 0-5 below |
17
- | JSON | sample API response, Figma API/plugin export | Direct-translation, low inference |
18
- | Markdown | a written schema spec/data-dictionary document | Direct-translation, low inference |
19
- | SQL DDL | `CREATE TABLE` statements/script, no live DB required | Direct-translation — see dedicated section below |
20
-
21
- A "Figma export" is not its own category — it surfaces as either an Image
22
- (screenshot/PNG export) or JSON (Figma API/plugin export), handled under
23
- whichever of those two it actually is.
24
-
25
- HTML and Image need **inference**: presentation must be classified into
26
- stored/derived/snapshot/joined/relation/audit before any column is decided
27
- (Step 0-5 below). JSON, Markdown, and SQL DDL are **already explicit** about
28
- types and constraints — the work is mostly direct translation to catalog
29
- syntax, with far less judgment-call risk. Do not run the Step 0-5
30
- classification ceremony on these; map what is already stated.
31
-
32
- Any source outside these 5 kinds (PDF requirements doc, verbal description,
33
- ERD diagram tool export not covered above, etc.) is out of scope for this
34
- reference — say so explicitly and ask how to proceed rather than improvising
35
- a procedure for it.
36
-
37
- ---
38
-
39
- ## Why a procedure is needed
40
-
41
- A design shows *presentation*, not *storage*. The same screen mixes four kinds
42
- of things that map very differently to a schema:
43
-
44
- | Kind | Example in design | SDF treatment |
45
- |---|---|---|
46
- | Stored column | "Category Name" text input | a field in `fields` |
47
- | Derived value | "No of Items" count in a table | NOT a column — computed via relation/query |
48
- | Snapshot value | "Unit Price", "Grand Total" on an order | a field in `fields` — looks computed but must be frozen |
49
- | Joined attribute | "Customer Email" on an order detail page | NOT a column on this table — belongs to the related entity |
50
- | Relation | "Category" dropdown on an Items form | FK + `belongsTo` relation |
51
- | Audit/system | "Created On", "Last Updated" | audit columns, not new fields |
52
-
53
- Reading every visible label as a column is the most common error. Classify
54
- first, then assign types.
55
-
56
- ---
57
-
58
- ## Step 0 — Confirm this is an SDF case at all
59
-
60
- Not every design implies a new table. A **dashboard/analytics screen** maps to
61
- the Dashboard RDF pipeline (`codegen_get_dashboard_catalog` →
62
- `codegen_create_dashboard`, payload with `widgets` not `tableName`, page name
63
- prefixed `dash-`), NOT to a new SDF table. Treating its numbers as columns
64
- produces a fictitious table — e.g. a `dashboard` model with fields like
65
- `sales`, `purchases`, `profit` is always wrong; those are query results, not
66
- data ever written by an insert/update.
67
-
68
- Signals that the source is a dashboard, not an entity to model:
69
- - a global filter that parameterizes the whole view — a date range, a
70
- cross-entity dropdown (e.g. "Filter by warehouse") — rather than a filter
71
- scoped to one entity's list,
72
- - stat cards / KPI tiles showing currency or count aggregates (Sales,
73
- Purchases, Profit, Total Due) with no per-card edit affordance,
74
- - a chart, especially a time-series one,
75
- - the absence of all three Step 1 signals (no Add/Edit form, no per-entity
76
- filter, no row-level list) in their normal shape.
77
-
78
- If any of these are present, stop before running Step 1-5. Instead:
79
- 1. Identify which **already-existing** tables the numbers aggregate from (here:
80
- `sale`, `purchase`, `sales_return`, `purchase_return`, `invoice`,
81
- `warehouse`). These need their own SDF only if they don't already exist —
82
- derived the normal way (via their own forms/lists), never from the
83
- dashboard screen itself.
84
- 2. Ground via `codegen_get_dashboard_catalog`, then build the dashboard payload
85
- with SQL widgets querying those tables, filtered by the date range /
86
- warehouse parameters shown.
87
- 3. Do not call `codegen_dbschema_init`/`migrate` for the dashboard itself —
88
- there is no table to create for it.
89
-
90
- A design can mix both: a page with a dashboard section AND a genuine CRUD form
91
- elsewhere. Apply this gate per-section, not to the whole file at once.
92
-
93
- ---
94
-
95
- ## Step 1 — Locate the authoritative element
96
-
97
- Prefer signals in this order. Earlier signals are more reliable than later.
98
-
99
- 1. **Add/Edit form (modal or page)** — the create/update form is the single
100
- best source of *stored* columns. Every editable input is a candidate column.
101
- 2. **Filter panel** — reveals enumerable values (status options) and filterable
102
- relations (category dropdown).
103
- 3. **Table/list `<thead>`** — confirms which columns are *displayed*, but mixes
104
- stored, derived, and relation-label columns. Use to cross-check, not as the
105
- primary source.
106
-
107
- A field that appears only in the table but never in the form is usually
108
- **derived** or a **relation label**, not a stored column.
109
-
110
- ### When the given source has no form
111
-
112
- A read-only detail/view page (e.g. an order "details" screen with no `<input>`)
113
- is NOT an authoritative source by itself — it is a list/table-equivalent (signal
114
- 3), not signal 1. Before classifying from it directly:
115
-
116
- - Look for a sibling Add/Edit page or modal. List and detail pages commonly link
117
- to it (e.g. an "Edit" button pointing to `edit-order.html` / `add-order.html`).
118
- If found, treat that form as the authoritative source for Step 2 and use the
119
- detail page only to cross-check.
120
- - If no Add/Edit form exists anywhere in the provided source(s), say so
121
- explicitly and proceed with the detail page as the best available signal —
122
- do not silently treat it as equivalent to a form.
123
-
124
- ---
125
-
126
- ## Step 2 — Classify each candidate
127
-
128
- For every input found in the form, decide its kind before its type.
129
-
130
- - **Stored column** — free input the user types/picks and that belongs to *this*
131
- entity. → goes in `fields`.
132
- - **Relation (FK)** — a dropdown/autocomplete that selects *another* entity
133
- (e.g. an Items form with a "Category" select). The stored column is the
134
- foreign key (`category_id`), and a `belongsTo` relation is added. The visible
135
- label ("Category name") is NOT stored on this table.
136
- - The FK reference uses the **actual PK column name** of the target table:
137
- `fk:category.category_id`, NOT `fk:category.id`. RESTForge does not resolve
138
- `.id` to the primary key — a non-existent target column is rejected.
139
- - A `*` on the dropdown means the relation is mandatory → add `notnull` to the
140
- FK column. Do NOT downgrade a required relation to a nullable free-text
141
- label (e.g. modelling a required "Tax" select as `tax_label string:50` drops
142
- both the relation and the constraint — wrong on two counts).
143
- - If a target entity for the dropdown plausibly exists (its own admin/settings
144
- page, e.g. Tax → tax-settings), model it as an FK. Only fall back to an enum
145
- `checks` set when the options are a small fixed list with no backing entity.
146
- - **Enum** — a select/radio whose options are a small, closed, *permanent* set
147
- intrinsic to the data (e.g. Gender: Male / Female; Status: Active / Inactive;
148
- Tax Type: Inclusive / Exclusive). → stored as `string` + a `checks` entry with
149
- `in: [...]`. No separate table.
150
- - **Lookup master** — a dropdown whose options are *managed data* that can grow,
151
- shrink, or change over time, or that carry extra attributes (a rate, an image,
152
- a description). → model as a separate **master table** and an FK, never as an
153
- enum. See the decision rule below.
154
- - **Joined attribute** — a value displayed on this screen but that is actually
155
- a column of a *related* entity, shown via join (e.g. "Customer Email" /
156
- "Customer Phone" on an order detail page — these belong to `customer`, not
157
- `order`). → NOT a column on this table. Confirm: does this entity already have
158
- its own table where the field belongs? If the related entity's FK is already
159
- modelled (e.g. `customer_id`), the attribute is reachable through that
160
- relation and must not be duplicated here.
161
- - **Derived vs. Snapshot** — both look like "a number that's computed", but they
162
- must be treated oppositely. Ask: *if the source data changes later, must this
163
- value stay frozen on this record, or is it allowed to change?*
164
- - **Derived** (allowed to change, recomputed live) — counts, running totals,
165
- "No of Items", "Total Orders". → NOT a column. Note it for an aggregate
166
- query (RDF `viewQuery`/`datatablesQuery`) or a `hasMany` relation count, and
167
- exclude from `fields`.
168
- - **Snapshot** (must stay frozen, computed or copied once at the moment of the
169
- transaction) — IS a column, even though it looks computed or looks like it
170
- duplicates a related entity's field. The principle is not limited to money:
171
- anything copied from a related entity onto a transaction record, where that
172
- related entity could change *after* the transaction while the transaction
173
- must keep showing the original value at the time. Excluding it from
174
- `fields` lets historical records change retroactively when the source
175
- changes — a data-integrity bug, not a simplification. Recognize it by
176
- asking "could the source of this value be edited or deleted later, while
177
- this record must still show what was true at transaction time?" — examples:
178
- - money: line `unit_price`, `subtotal`, `tax_amount`, `shipping_rate`,
179
- `grand_total` on an order; a price/rate copied from a master table.
180
- - identity/labels: a product's `name`/`sku` copied onto an order line (the
181
- product can be renamed or discontinued later; the historical order line
182
- must still show what was ordered).
183
- - location: a `shipping_address`/`billing_address` copied onto an order
184
- (the customer's address book entry can change later; the order must keep
185
- shipping to where it said it would at the time of purchase).
186
- - This overlaps with **Joined attribute** above — both involve a value that
187
- *could* be reached via a relation. The deciding question is whether the
188
- value is allowed to drift when the related entity changes (joined — do not
189
- store) or must stay frozen (snapshot — store). When the field is tied to a
190
- specific transaction/sale and could plausibly change at the source later
191
- (price, product label, address), default to **snapshot** — under-storing
192
- breaks historical accuracy; over-storing only costs a redundant column.
193
- Pure identity/contact fields with no transactional freezing need (e.g. a
194
- customer's current email shown on their own profile page) stay **joined**.
195
- - **Audit/system** — "Created On/At", "Updated On/At", "Created By". → map to
196
- the standard audit columns, do not invent new fields.
197
-
198
- ### Decision rule — lookup master table vs enum
199
-
200
- When a field offers a fixed set of choices, decide between a **master table + FK**
201
- and an inline **enum (`checks`)** by permanence and management:
202
-
203
- - **Non-permanent / managed lookup → ALWAYS a master table + FK.** If the option
204
- set is business data that an admin adds to, edits, deactivates, or that carries
205
- extra attributes, it is a managed entity — model it as its own table and
206
- reference it by FK. Examples: Category, Brand, Tax, Unit, Warehouse, City.
207
- - **Simple, permanent, closed set → enum (`checks in:[...]`).** If the values are
208
- intrinsic to the record, will not be managed through a UI, and the set is
209
- stable, store a `string` with a `checks` constraint. Examples: Gender
210
- (`MALE`/`FEMALE`), Active/Inactive status, Yes/No, Tax Type
211
- (Inclusive/Exclusive).
212
-
213
- These examples are illustrative, not fixed labels — the same field name can be
214
- either depending on the actual design. Decide every case by the signals below,
215
- not by matching the field's name against an example list.
216
-
217
- Decisive signals for a master table (any one is sufficient):
218
- - a dedicated admin/settings page exists for it (e.g. Tax → `tax-settings`,
219
- Category → `categories`),
220
- - options carry their own attributes (rate, image, description, code),
221
- - the business is expected to add/remove options over time,
222
- - a quick-add affordance sits directly on the dropdown in the same form (e.g. a
223
- "+" button next to "Choose Category" / "Choose Brand") — this is direct UI
224
- evidence of a managed entity and needs no separate admin page to confirm it.
225
-
226
- The absence of a signal is not evidence for the opposite conclusion. In
227
- particular, a dropdown with no "+" button is NOT thereby an enum — check the
228
- other signals before concluding enum. A dropdown whose options are themselves
229
- children of an already-managed entity (e.g. "Subcategories" depending on a
230
- selected "Category") is still a master-table FK even with no quick-add button
231
- of its own, because it inherits "managed" status from its parent entity.
232
-
233
- Without any of these signals present in the provided source(s), do not assume
234
- a master table — a label by itself (e.g. "Payment Method: Credit Card" on an
235
- order detail page, with no payment-method admin page or quick-add affordance in
236
- evidence) defaults to an enum. "Payment Method" in particular varies by system:
237
- a simple storefront with a fixed set of methods is an enum; a system with a
238
- payment-gateway settings page (fees, codes, enable/disable per method) is a
239
- master table. Confirm with the user when the source gives no signal either way.
240
-
241
- When uncertain, prefer the **master table** — promoting an enum to a table later
242
- is a breaking migration, while a stable enum rarely needs to become a table.
243
- Note: a master table can itself contain a permanent enum column (e.g. the `tax`
244
- table stores `tax_type` as an enum).
245
-
246
- ---
247
-
248
- ## Step 3 — Assign SDF types
249
-
250
- Map the HTML control (or the visual control in an image) to a catalog field
251
- type. All types below are from `codegen_get_dbschema_catalog`.
252
-
253
- | Design control | SDF type | Notes |
254
- |---|---|---|
255
- | Short text input | `string:N` | N = confirm; default 255 if no hint |
256
- | Long text / `<textarea>` | `text` | description, notes, address |
257
- | Number (count, qty) | `integer` / `bigint` | bigint only if very large |
258
- | Money / price | `decimal:15,2` | currency; scale 2 unless design says otherwise |
259
- | Percentage / rate | `decimal:5,2` | confirm precision |
260
- | Single checkbox / toggle | `boolean` | true/false only |
261
- | Status / type select | `string:20` + `checks in:[...]` | enum, not boolean, when >2 states |
262
- | Date picker | `date` | date only |
263
- | Date + time | `timestamp` | |
264
- | File / image upload | `string:500` | store the path/URL, never the binary |
265
- | Email | `string:255` | |
266
- | Phone | `string:30` | |
267
- | Color picker / code | `string:7` or `string:36` | hex vs token |
268
- | Entity dropdown (FK) | `string:36` or `integer` + `fk:<table>.<col>` | match the target PK type |
269
- | JSON / key-value block | `json` | |
270
-
271
- Rules carried from the catalog:
272
- - `string` always needs a length; `decimal` always needs precision,scale.
273
- - A `*` / "required" marker → add `notnull`.
274
- - A select with fixed options → `checks: [{ field, in: [...] }]`, options
275
- lowercased/normalized to storage values.
276
- - FK uses dot notation `fk:<table>.<column>`; also add the `belongsTo` relation
277
- with `onDelete` (default `restrict`; confirm).
278
-
279
- ---
280
-
281
- ## Step 4 — Add structural scaffolding
282
-
283
- A design rarely shows these, but a RESTForge table needs them:
284
-
285
- - **Primary key** — add `<entity>_id` as `string:36 pk` (UUID style) or
286
- `integer pk` (serial style). Confirm the convention with the user/project.
287
- - **Audit columns** — add the four standard audit columns
288
- (`created_at`, `created_by`, `updated_at`, `updated_by`) unless this is a pure
289
- lookup table. "Created On" in the design maps here.
290
- - **Soft-delete** — only if the design shows a recycle bin / "restore" / archive
291
- affordance. Then apply the soft-delete contract (PostgreSQL only).
292
-
293
- ---
294
-
295
- ## Step 5 — Validate and confirm
296
-
297
- 1. Write the draft SDF (`defineModel`) using only catalog options.
298
- 2. Run `codegen_dbschema_validate`.
299
- 3. Present the **confirm list** to the user before migrating:
300
- - every guessed `string` length and `decimal` precision,
301
- - every enum option set,
302
- - every FK target and its `onDelete`,
303
- - PK convention (uuid vs serial),
304
- - any "derived" column excluded from the schema (state it explicitly so the
305
- omission is intentional, not silent).
306
-
307
- Never call `codegen_dbschema_migrate`/`apply` on a design-derived SDF without
308
- this confirmation.
309
-
310
- ---
311
-
312
- ## SQL DDL source — direct translation path
313
-
314
- Applies when the source is `CREATE TABLE` statements (a `.sql` file or a
315
- pasted script), with or without a live database to run it against. This is
316
- the lowest-inference path: the DDL already states every type and constraint
317
- explicitly. Do not run Step 0-5 — translate what is written, syntax to
318
- syntax, dialect to dialect-agnostic SDF.
319
-
320
- ### Type mapping
321
-
322
- | SQL DDL type | SDF type |
323
- |---|---|
324
- | `VARCHAR(n)`, `CHARACTER VARYING(n)`, bounded `CHAR(n)` | `string:n` |
325
- | `TEXT`, `CLOB`, unbounded `NVARCHAR` | `text` |
326
- | `INT`, `INTEGER`, `INT4` | `integer` |
327
- | `BIGINT`, `INT8` | `bigint` |
328
- | `DECIMAL(p,s)`, `NUMERIC(p,s)` | `decimal:p,s` |
329
- | `BOOLEAN`, `BOOL`, legacy `TINYINT(1)` flag | `boolean` |
330
- | `DATE` | `date` |
331
- | `TIMESTAMP`, `TIMESTAMPTZ`, `DATETIME` | `timestamp` |
332
- | `UUID`, `UNIQUEIDENTIFIER` | `uuid` |
333
- | `JSON`, `JSONB` | `json` |
334
-
335
- ### Constraint mapping
336
-
337
- | SQL DDL | SDF |
338
- |---|---|
339
- | `PRIMARY KEY` | `pk` |
340
- | `NOT NULL` | `notnull` |
341
- | `UNIQUE` | `unique` |
342
- | non-unique `INDEX`/`KEY` | `index` |
343
- | `DEFAULT value` | `default:value` — same quoting rule as the catalog: string quoted, numeric/boolean raw, SQL constant bare, function e.g. `now()` |
344
- | `CHECK (col IN (...))` | `checks: [{ field, in: [...] }]` |
345
- | `CHECK (col =/!=/>/>=/</<= value)` | `checks: [{ field, eq/neq/gt/gte/lt/lte: value }]` |
346
- | composite `PRIMARY KEY`/`UNIQUE` (multiple columns) | see `composite-primary-key.md` / `composite-unique.md` in the handbook catalog — do not improvise the syntax |
347
-
348
- ### Foreign keys — shorthand vs `relations`
349
-
350
- Apply the same mutual-exclusivity rule as the catalog (`foreign-keys.md`):
351
- - `FOREIGN KEY ... REFERENCES table(col)` with no `ON DELETE`/`ON UPDATE`
352
- clause (or only the dialect default) → `fk:table.col` shorthand.
353
- - `FOREIGN KEY ... REFERENCES table(col) ON DELETE x ON UPDATE y` with an
354
- explicit, non-default action → a `relations` entry with that `onDelete`/
355
- `onUpdate`. Do NOT also write the `fk:` shorthand on the same field — the
356
- catalog rejects having both.
357
- - The reference column is always the **actual column name** named in
358
- `REFERENCES table(column)` — this is given explicitly in DDL, so there is no
359
- `.id`-guessing risk here (unlike inferring FK targets from a UI dropdown).
360
-
361
- ### What NOT to add
362
-
363
- Unlike the HTML/Image path, DDL is already a deliberate, complete spec from a
364
- real system — do not apply Step 4's scaffolding defaults on top of it:
365
- - Do **not** auto-add audit columns (`created_at`/`created_by`/`updated_at`/
366
- `updated_by`) if the DDL doesn't have them. Their absence is the source
367
- system's actual design, not a gap to fill.
368
- - Do **not** convert the primary key strategy. If the DDL uses
369
- `SERIAL`/`AUTO_INCREMENT`/`GENERATED ALWAYS AS IDENTITY` (integer PK), keep
370
- `integer pk` — do not silently switch it to a `string:36` UUID PK because
371
- that happens to be the convention used elsewhere.
372
-
373
- ### Confirm-list additions specific to DDL
374
-
375
- - If the DDL already has `is_deleted`/`deleted_at`/`deleted_by` columns, this
376
- is a soft-delete table — set `softDelete.enabled: true` and verify the
377
- target dialect is PostgreSQL (Phase 1 only); flag a conflict explicitly if
378
- the DDL's dialect is MySQL/Oracle/SQLite.
379
- - If the DDL's original dialect differs from the project's target dialect
380
- (e.g. migrating MySQL DDL into a PostgreSQL project), call out type/storage
381
- differences (e.g. MySQL boolean-as-`TINYINT(1)`) — the SDF type stays the
382
- dialect-agnostic logical type (`boolean`), the platform handles physical
383
- storage per target dialect.
384
- - Named constraints in the source DDL (`pk_xxx`, `fk_xxx`) are not preserved —
385
- RESTForge generates its own deterministic constraint names on migrate. Note
386
- this only matters if something outside RESTForge depends on the original
387
- constraint names.
388
-
389
- ---
390
-
391
- ## Worked example — categories.html
392
-
393
- Design signals: table columns (Category, No of Items, Created On, Status,
394
- Actions); Add/Edit form (Category Image *, Category Name *); filter (Status:
395
- Active / Inactive).
396
-
397
- Classification:
398
- - `Category Name` → stored, required → `string:255 notnull`.
399
- - `Category Image` → file upload → `string:500` (path/URL).
400
- - `Status` → enum (Active/Inactive) → `string:20 default:'active'` + check.
401
- - `No of Items` → **derived** (count of `items` with FK to category) → excluded
402
- from `fields`; expose later via aggregate query or `hasMany` count.
403
- - `Created On` → audit → `created_at`.
404
- - `Actions` → UI only → ignored.
405
-
406
- Resulting draft SDF:
407
-
408
- ```js
409
- defineModel("category", {
410
- fields: {
411
- category_id: "string:36 pk",
412
- category_name: "string:255 notnull",
413
- image_url: "string:500",
414
- status: "string:20 notnull default:'active'",
415
- created_at: "timestamp default:now()",
416
- created_by: "string:100",
417
- updated_at: "timestamp",
418
- updated_by: "string:100"
419
- },
420
- checks: [
421
- { field: "status", in: ["active", "inactive"] }
422
- ]
423
- })
424
- ```
425
-
426
- Confirm before migrate: `category_name` length (255?), `status` option values,
427
- PK convention (uuid vs serial), and that `No of Items` is intentionally derived,
428
- not stored.
429
-
430
- ---
431
-
432
- ## Worked example — items.html (master-detail + lookup decision)
433
-
434
- Design signals: Add Item form (Item Image *, Item Name *, Description *,
435
- Price *, Net Price *, Category * dropdown, Tax * dropdown); two repeatable
436
- accordion sections inside the same form — "Variations" (Size *, Price * per
437
- row) and "Add Ons" (Name *, Price($) * per row); item cards in the list show
438
- a Veg/Non Veg badge not present anywhere in the Add form.
439
-
440
- This source exercises three rules the categories.html example doesn't:
441
- FK reference correctness, the lookup-master-vs-enum decision on two fields
442
- from the *same* form, and master-detail decomposition.
443
-
444
- Classification:
445
- - `Item Name`, `Description`, `Price`, `Net Price` → stored, required.
446
- - `Item Image` → file upload → `string:255`.
447
- - `Category *` → **FK to a master table**. Decisive signal: a dedicated
448
- `categories.html` admin page exists for it elsewhere in the same design set.
449
- → `category_id string:36 fk:category.category_id notnull` — the target
450
- column is `category.category_id` (the table's actual PK), not `category.id`.
451
- - `Tax *` → **FK to a master table**, same reasoning — a dedicated
452
- `tax-settings.html` page exists. → `tax_id string:36 fk:tax.tax_id notnull`.
453
- The `tax` table itself is derived separately from its own admin page (title,
454
- rate, tax type), not invented here.
455
- - `Veg / Non Veg` badge → **enum**, even though it appears only in the list,
456
- not the Add form. It's a small, permanent, intrinsic set with no admin page
457
- of its own (contrast with `Tax`, which looks similar — a fixed-looking
458
- label — but does have one). → `food_type string:20 default:'non_veg'` +
459
- `checks: [{ field: 'food_type', in: ['veg', 'non_veg'] }]`.
460
- - "Variations" accordion (repeatable Size + Price rows) → **master-detail
461
- child table**, not columns on `item`. One row in the design = one row in a
462
- new `item_variation` table with `item_id` FK back to `item`.
463
- - "Add Ons" accordion (repeatable Name + Price rows) → same pattern, a second
464
- child table `item_addon`.
465
-
466
- Resulting draft SDF (four tables — `tax` is shown in outline; the master
467
- entity and both detail tables in full):
468
-
469
- ```js
470
- defineModel("tax", {
471
- fields: {
472
- tax_id: "string:36 pk",
473
- title: "string:100 notnull",
474
- rate: "decimal:5,2 notnull",
475
- tax_type: "string:20 notnull default:'exclusive'",
476
- created_at: "timestamp default:now()",
477
- created_by: "string:100",
478
- updated_at: "timestamp",
479
- updated_by: "string:100"
480
- },
481
- checks: [
482
- { field: "tax_type", in: ["inclusive", "exclusive"] }
483
- ]
484
- });
485
-
486
- defineModel("item", {
487
- fields: {
488
- item_id: "string:36 pk",
489
- category_id: "string:36 fk:category.category_id notnull",
490
- item_name: "string:150 notnull",
491
- description: "text notnull",
492
- item_image: "string:255 notnull",
493
- food_type: "string:20 notnull default:'non_veg'",
494
- price: "decimal:10,2 notnull",
495
- net_price: "decimal:10,2 notnull",
496
- tax_id: "string:36 fk:tax.tax_id notnull",
497
- created_at: "timestamp default:now()",
498
- created_by: "string:100",
499
- updated_at: "timestamp",
500
- updated_by: "string:100"
501
- },
502
- checks: [
503
- { field: "food_type", in: ["veg", "non_veg"] }
504
- ]
505
- });
506
-
507
- defineModel("item_variation", {
508
- fields: {
509
- item_variation_id: "string:36 pk",
510
- item_id: "string:36 fk:item.item_id notnull",
511
- size_name: "string:50 notnull",
512
- price: "decimal:10,2 notnull",
513
- created_at: "timestamp default:now()",
514
- created_by: "string:100",
515
- updated_at: "timestamp",
516
- updated_by: "string:100"
517
- }
518
- });
519
-
520
- defineModel("item_addon", {
521
- fields: {
522
- item_addon_id: "string:36 pk",
523
- item_id: "string:36 fk:item.item_id notnull",
524
- addon_name: "string:100 notnull",
525
- price: "decimal:10,2 notnull",
526
- created_at: "timestamp default:now()",
527
- created_by: "string:100",
528
- updated_at: "timestamp",
529
- updated_by: "string:100"
530
- }
531
- });
532
- ```
533
-
534
- Confirm before migrate: `item_name`/`item_image` lengths, `food_type` default
535
- value, whether `tax` needs more fields than the admin page showed, and that
536
- both accordions are intentionally separate tables rather than columns on
537
- `item`.
538
-
539
- ---
540
-
541
- ## Worked example — Order Details (no-form fallback + joined attribute + snapshot)
542
-
543
- Design signals: a read-only "Order Details" page — **no Add/Edit form is
544
- present in this source**. Panels: Order Info (Date Added, Payment Method,
545
- Status), Customer Details (Name, Email, Phone, Country, State, Address),
546
- Order Items (Product, Qty, Unit Price → line Total), summary (Subtotal, Tax,
547
- Shipping Rate, Grand Total).
548
-
549
- This source exercises three rules the previous two examples don't: the
550
- no-form fallback, the joined-attribute exclusion, and the
551
- derived-vs-snapshot distinction — including two fields (Address vs
552
- Name/Email/Phone) that look identical in presentation but classify oppositely.
553
-
554
- Classification:
555
- - **No form in this source** → state that explicitly per the Step 1 fallback,
556
- proceed with this detail page as the best available signal rather than
557
- silently treating it as equivalent to a form.
558
- - `Payment Method` → **enum**, not a master table. No admin/settings page or
559
- quick-add affordance is present anywhere in this source.
560
- - `Status` → enum, closed permanent set (`pending`/`processing`/`completed`/
561
- `cancelled`).
562
- - `Customer Name`, `Email`, `Phone` → **joined attribute**. These belong to
563
- `customer`, reachable through `customer_id`; not stored on `order`.
564
- - `Country`, `State`, `Address` → **snapshot**, not joined — even though they
565
- look like the same kind of "customer info" as Name/Email/Phone just above
566
- them. The deciding question: could the customer's saved address change
567
- later while this order must still show where it actually shipped? Yes →
568
- must stay frozen on the order record.
569
- - `Product`, `Qty` → relation (FK to `product`) + stored quantity, on a
570
- master-detail child table `order_item` (one row per line, same pattern as
571
- `item_variation` in the previous example).
572
- - `Unit Price` (per line) and `Subtotal`/`Tax`/`Shipping Rate`/`Grand Total`
573
- (header summary) → **snapshot**. All are computed, but must not silently
574
- change if the product's current price changes after the order is placed.
575
-
576
- Resulting draft SDF:
577
-
578
- ```js
579
- defineModel("order", {
580
- fields: {
581
- order_id: "string:36 pk",
582
- order_number: "string:20 notnull unique",
583
- customer_id: "string:36 fk:customer.customer_id notnull",
584
- payment_method: "string:20 notnull",
585
- status: "string:20 notnull default:'pending'",
586
- shipping_address: "text notnull",
587
- shipping_country: "string:100 notnull",
588
- shipping_state: "string:100 notnull",
589
- subtotal: "decimal:15,2 notnull",
590
- tax_amount: "decimal:15,2 notnull default:0",
591
- shipping_rate: "decimal:15,2 notnull default:0",
592
- grand_total: "decimal:15,2 notnull",
593
- created_at: "timestamp default:now()",
594
- created_by: "string:100"
595
- },
596
- checks: [
597
- { field: "payment_method", in: ["credit_card", "bank_transfer", "cash", "e_wallet"] },
598
- { field: "status", in: ["pending", "processing", "completed", "cancelled"] }
599
- ]
600
- });
601
-
602
- defineModel("order_item", {
603
- fields: {
604
- order_item_id: "string:36 pk",
605
- order_id: "string:36 fk:order.order_id notnull",
606
- product_id: "string:36 fk:product.product_id notnull",
607
- qty: "integer notnull",
608
- unit_price: "decimal:15,2 notnull"
609
- }
610
- });
611
- ```
612
-
613
- Confirm before migrate: `payment_method` option set (this source gave no
614
- admin-page signal — verify with the user whether the real system manages it
615
- as a master table instead), `order_number` format/length, PK convention, and
616
- that Name/Email/Phone were intentionally excluded as joined while
617
- Address/totals were intentionally kept as snapshot — these two decisions look
618
- similar on the screen but are opposite in the schema.
1
+ # Reference: Design-to-SDF Heuristics
2
+
3
+ > **Procedure, not a catalog dump.** This file is a classification procedure that
4
+ > produces a *draft* SDF. It does not replace `codegen_get_dbschema_catalog`:
5
+ > every draft is grounded against the catalog for valid options and MUST pass
6
+ > `codegen_dbschema_validate` before any DDL is generated. Inferences marked
7
+ > "confirm" are guesses — surface them to the user, never commit silently.
8
+
9
+ Use when the goal is to derive an SDF (Schema Definition File) table structure
10
+ from an external source. Exactly 5 source kinds are in scope — anything else
11
+ is out of scope and must be refused rather than guessed at:
12
+
13
+ | Source kind | Examples | Path |
14
+ |---|---|---|
15
+ | HTML | a UI mockup/template file | Inference path — Step 0-5 below |
16
+ | Image | screenshot, photo, pasted clipboard image (jpg/png) | Inference path — Step 0-5 below |
17
+ | JSON | sample API response, Figma API/plugin export | Direct-translation, low inference |
18
+ | Markdown | a written schema spec/data-dictionary document | Direct-translation, low inference |
19
+ | SQL DDL | `CREATE TABLE` statements/script, no live DB required | Direct-translation — see dedicated section below |
20
+
21
+ A "Figma export" is not its own category — it surfaces as either an Image
22
+ (screenshot/PNG export) or JSON (Figma API/plugin export), handled under
23
+ whichever of those two it actually is.
24
+
25
+ HTML and Image need **inference**: presentation must be classified into
26
+ stored/derived/snapshot/joined/relation/audit before any column is decided
27
+ (Step 0-5 below). JSON, Markdown, and SQL DDL are **already explicit** about
28
+ types and constraints — the work is mostly direct translation to catalog
29
+ syntax, with far less judgment-call risk. Do not run the Step 0-5
30
+ classification ceremony on these; map what is already stated.
31
+
32
+ Any source outside these 5 kinds (PDF requirements doc, verbal description,
33
+ ERD diagram tool export not covered above, etc.) is out of scope for this
34
+ reference — say so explicitly and ask how to proceed rather than improvising
35
+ a procedure for it.
36
+
37
+ ---
38
+
39
+ ## Why a procedure is needed
40
+
41
+ A design shows *presentation*, not *storage*. The same screen mixes four kinds
42
+ of things that map very differently to a schema:
43
+
44
+ | Kind | Example in design | SDF treatment |
45
+ |---|---|---|
46
+ | Stored column | "Category Name" text input | a field in `fields` |
47
+ | Derived value | "No of Items" count in a table | NOT a column — computed via relation/query |
48
+ | Snapshot value | "Unit Price", "Grand Total" on an order | a field in `fields` — looks computed but must be frozen |
49
+ | Joined attribute | "Customer Email" on an order detail page | NOT a column on this table — belongs to the related entity |
50
+ | Relation | "Category" dropdown on an Items form | FK + `belongsTo` relation |
51
+ | Audit/system | "Created On", "Last Updated" | audit columns, not new fields |
52
+
53
+ Reading every visible label as a column is the most common error. Classify
54
+ first, then assign types.
55
+
56
+ ---
57
+
58
+ ## Step 0 — Confirm this is an SDF case at all
59
+
60
+ Not every design implies a new table. A **dashboard/analytics screen** maps to
61
+ the Dashboard RDF pipeline (`codegen_get_dashboard_catalog` →
62
+ `codegen_create_dashboard`, payload with `widgets` not `tableName`, page name
63
+ prefixed `dash-`), NOT to a new SDF table. Treating its numbers as columns
64
+ produces a fictitious table — e.g. a `dashboard` model with fields like
65
+ `sales`, `purchases`, `profit` is always wrong; those are query results, not
66
+ data ever written by an insert/update.
67
+
68
+ Signals that the source is a dashboard, not an entity to model:
69
+ - a global filter that parameterizes the whole view — a date range, a
70
+ cross-entity dropdown (e.g. "Filter by warehouse") — rather than a filter
71
+ scoped to one entity's list,
72
+ - stat cards / KPI tiles showing currency or count aggregates (Sales,
73
+ Purchases, Profit, Total Due) with no per-card edit affordance,
74
+ - a chart, especially a time-series one,
75
+ - the absence of all three Step 1 signals (no Add/Edit form, no per-entity
76
+ filter, no row-level list) in their normal shape.
77
+
78
+ If any of these are present, stop before running Step 1-5. Instead:
79
+ 1. Identify which **already-existing** tables the numbers aggregate from (here:
80
+ `sale`, `purchase`, `sales_return`, `purchase_return`, `invoice`,
81
+ `warehouse`). These need their own SDF only if they don't already exist —
82
+ derived the normal way (via their own forms/lists), never from the
83
+ dashboard screen itself.
84
+ 2. Ground via `codegen_get_dashboard_catalog`, then build the dashboard payload
85
+ with SQL widgets querying those tables, filtered by the date range /
86
+ warehouse parameters shown.
87
+ 3. Do not call `codegen_dbschema_init`/`migrate` for the dashboard itself —
88
+ there is no table to create for it.
89
+
90
+ A design can mix both: a page with a dashboard section AND a genuine CRUD form
91
+ elsewhere. Apply this gate per-section, not to the whole file at once.
92
+
93
+ ---
94
+
95
+ ## Step 1 — Locate the authoritative element
96
+
97
+ Prefer signals in this order. Earlier signals are more reliable than later.
98
+
99
+ 1. **Add/Edit form (modal or page)** — the create/update form is the single
100
+ best source of *stored* columns. Every editable input is a candidate column.
101
+ 2. **Filter panel** — reveals enumerable values (status options) and filterable
102
+ relations (category dropdown).
103
+ 3. **Table/list `<thead>`** — confirms which columns are *displayed*, but mixes
104
+ stored, derived, and relation-label columns. Use to cross-check, not as the
105
+ primary source.
106
+
107
+ A field that appears only in the table but never in the form is usually
108
+ **derived** or a **relation label**, not a stored column.
109
+
110
+ ### When the given source has no form
111
+
112
+ A read-only detail/view page (e.g. an order "details" screen with no `<input>`)
113
+ is NOT an authoritative source by itself — it is a list/table-equivalent (signal
114
+ 3), not signal 1. Before classifying from it directly:
115
+
116
+ - Look for a sibling Add/Edit page or modal. List and detail pages commonly link
117
+ to it (e.g. an "Edit" button pointing to `edit-order.html` / `add-order.html`).
118
+ If found, treat that form as the authoritative source for Step 2 and use the
119
+ detail page only to cross-check.
120
+ - If no Add/Edit form exists anywhere in the provided source(s), say so
121
+ explicitly and proceed with the detail page as the best available signal —
122
+ do not silently treat it as equivalent to a form.
123
+
124
+ ---
125
+
126
+ ## Step 2 — Classify each candidate
127
+
128
+ For every input found in the form, decide its kind before its type.
129
+
130
+ - **Stored column** — free input the user types/picks and that belongs to *this*
131
+ entity. → goes in `fields`.
132
+ - **Relation (FK)** — a dropdown/autocomplete that selects *another* entity
133
+ (e.g. an Items form with a "Category" select). The stored column is the
134
+ foreign key (`category_id`), and a `belongsTo` relation is added. The visible
135
+ label ("Category name") is NOT stored on this table.
136
+ - The FK reference uses the **actual PK column name** of the target table:
137
+ `fk:category.category_id`, NOT `fk:category.id`. RESTForge does not resolve
138
+ `.id` to the primary key — a non-existent target column is rejected.
139
+ - A `*` on the dropdown means the relation is mandatory → add `notnull` to the
140
+ FK column. Do NOT downgrade a required relation to a nullable free-text
141
+ label (e.g. modelling a required "Tax" select as `tax_label string:50` drops
142
+ both the relation and the constraint — wrong on two counts).
143
+ - If a target entity for the dropdown plausibly exists (its own admin/settings
144
+ page, e.g. Tax → tax-settings), model it as an FK. Only fall back to an enum
145
+ `checks` set when the options are a small fixed list with no backing entity.
146
+ - **Enum** — a select/radio whose options are a small, closed, *permanent* set
147
+ intrinsic to the data (e.g. Gender: Male / Female; Status: Active / Inactive;
148
+ Tax Type: Inclusive / Exclusive). → stored as `string` + a `checks` entry with
149
+ `in: [...]`. No separate table.
150
+ - **Lookup master** — a dropdown whose options are *managed data* that can grow,
151
+ shrink, or change over time, or that carry extra attributes (a rate, an image,
152
+ a description). → model as a separate **master table** and an FK, never as an
153
+ enum. See the decision rule below.
154
+ - **Joined attribute** — a value displayed on this screen but that is actually
155
+ a column of a *related* entity, shown via join (e.g. "Customer Email" /
156
+ "Customer Phone" on an order detail page — these belong to `customer`, not
157
+ `order`). → NOT a column on this table. Confirm: does this entity already have
158
+ its own table where the field belongs? If the related entity's FK is already
159
+ modelled (e.g. `customer_id`), the attribute is reachable through that
160
+ relation and must not be duplicated here.
161
+ - **Derived vs. Snapshot** — both look like "a number that's computed", but they
162
+ must be treated oppositely. Ask: *if the source data changes later, must this
163
+ value stay frozen on this record, or is it allowed to change?*
164
+ - **Derived** (allowed to change, recomputed live) — counts, running totals,
165
+ "No of Items", "Total Orders". → NOT a column. Note it for an aggregate
166
+ query (RDF `viewQuery`/`datatablesQuery`) or a `hasMany` relation count, and
167
+ exclude from `fields`.
168
+ - **Snapshot** (must stay frozen, computed or copied once at the moment of the
169
+ transaction) — IS a column, even though it looks computed or looks like it
170
+ duplicates a related entity's field. The principle is not limited to money:
171
+ anything copied from a related entity onto a transaction record, where that
172
+ related entity could change *after* the transaction while the transaction
173
+ must keep showing the original value at the time. Excluding it from
174
+ `fields` lets historical records change retroactively when the source
175
+ changes — a data-integrity bug, not a simplification. Recognize it by
176
+ asking "could the source of this value be edited or deleted later, while
177
+ this record must still show what was true at transaction time?" — examples:
178
+ - money: line `unit_price`, `subtotal`, `tax_amount`, `shipping_rate`,
179
+ `grand_total` on an order; a price/rate copied from a master table.
180
+ - identity/labels: a product's `name`/`sku` copied onto an order line (the
181
+ product can be renamed or discontinued later; the historical order line
182
+ must still show what was ordered).
183
+ - location: a `shipping_address`/`billing_address` copied onto an order
184
+ (the customer's address book entry can change later; the order must keep
185
+ shipping to where it said it would at the time of purchase).
186
+ - This overlaps with **Joined attribute** above — both involve a value that
187
+ *could* be reached via a relation. The deciding question is whether the
188
+ value is allowed to drift when the related entity changes (joined — do not
189
+ store) or must stay frozen (snapshot — store). When the field is tied to a
190
+ specific transaction/sale and could plausibly change at the source later
191
+ (price, product label, address), default to **snapshot** — under-storing
192
+ breaks historical accuracy; over-storing only costs a redundant column.
193
+ Pure identity/contact fields with no transactional freezing need (e.g. a
194
+ customer's current email shown on their own profile page) stay **joined**.
195
+ - **Audit/system** — "Created On/At", "Updated On/At", "Created By". → map to
196
+ the standard audit columns, do not invent new fields.
197
+
198
+ ### Decision rule — lookup master table vs enum
199
+
200
+ When a field offers a fixed set of choices, decide between a **master table + FK**
201
+ and an inline **enum (`checks`)** by permanence and management:
202
+
203
+ - **Non-permanent / managed lookup → ALWAYS a master table + FK.** If the option
204
+ set is business data that an admin adds to, edits, deactivates, or that carries
205
+ extra attributes, it is a managed entity — model it as its own table and
206
+ reference it by FK. Examples: Category, Brand, Tax, Unit, Warehouse, City.
207
+ - **Simple, permanent, closed set → enum (`checks in:[...]`).** If the values are
208
+ intrinsic to the record, will not be managed through a UI, and the set is
209
+ stable, store a `string` with a `checks` constraint. Examples: Gender
210
+ (`MALE`/`FEMALE`), Active/Inactive status, Yes/No, Tax Type
211
+ (Inclusive/Exclusive).
212
+
213
+ These examples are illustrative, not fixed labels — the same field name can be
214
+ either depending on the actual design. Decide every case by the signals below,
215
+ not by matching the field's name against an example list.
216
+
217
+ Decisive signals for a master table (any one is sufficient):
218
+ - a dedicated admin/settings page exists for it (e.g. Tax → `tax-settings`,
219
+ Category → `categories`),
220
+ - options carry their own attributes (rate, image, description, code),
221
+ - the business is expected to add/remove options over time,
222
+ - a quick-add affordance sits directly on the dropdown in the same form (e.g. a
223
+ "+" button next to "Choose Category" / "Choose Brand") — this is direct UI
224
+ evidence of a managed entity and needs no separate admin page to confirm it.
225
+
226
+ The absence of a signal is not evidence for the opposite conclusion. In
227
+ particular, a dropdown with no "+" button is NOT thereby an enum — check the
228
+ other signals before concluding enum. A dropdown whose options are themselves
229
+ children of an already-managed entity (e.g. "Subcategories" depending on a
230
+ selected "Category") is still a master-table FK even with no quick-add button
231
+ of its own, because it inherits "managed" status from its parent entity.
232
+
233
+ Without any of these signals present in the provided source(s), do not assume
234
+ a master table — a label by itself (e.g. "Payment Method: Credit Card" on an
235
+ order detail page, with no payment-method admin page or quick-add affordance in
236
+ evidence) defaults to an enum. "Payment Method" in particular varies by system:
237
+ a simple storefront with a fixed set of methods is an enum; a system with a
238
+ payment-gateway settings page (fees, codes, enable/disable per method) is a
239
+ master table. Confirm with the user when the source gives no signal either way.
240
+
241
+ When uncertain, prefer the **master table** — promoting an enum to a table later
242
+ is a breaking migration, while a stable enum rarely needs to become a table.
243
+ Note: a master table can itself contain a permanent enum column (e.g. the `tax`
244
+ table stores `tax_type` as an enum).
245
+
246
+ ---
247
+
248
+ ## Step 3 — Assign SDF types
249
+
250
+ Map the HTML control (or the visual control in an image) to a catalog field
251
+ type. All types below are from `codegen_get_dbschema_catalog`.
252
+
253
+ | Design control | SDF type | Notes |
254
+ |---|---|---|
255
+ | Short text input | `string:N` | N = confirm; default 255 if no hint |
256
+ | Long text / `<textarea>` | `text` | description, notes, address |
257
+ | Number (count, qty) | `integer` / `bigint` | bigint only if very large |
258
+ | Money / price | `decimal:15,2` | currency; scale 2 unless design says otherwise |
259
+ | Percentage / rate | `decimal:5,2` | confirm precision |
260
+ | Single checkbox / toggle | `boolean` | true/false only |
261
+ | Status / type select | `string:20` + `checks in:[...]` | enum, not boolean, when >2 states |
262
+ | Date picker | `date` | date only |
263
+ | Date + time | `timestamp` | wall-clock time in the app `TIMEZONE`; use `timestamptz` only for an absolute moment shared across zones (PostgreSQL only) — confirm |
264
+ | Time picker (time of day only) | `time` | e.g. shift start; not supported on Oracle (use `timestamp` there) |
265
+ | File / image upload | `string:500` | store the path/URL, never the binary |
266
+ | Email | `string:255` | |
267
+ | Phone | `string:30` | |
268
+ | Color picker / code | `string:7` or `string:36` | hex vs token |
269
+ | Entity dropdown (FK) | `string:36` or `integer` + `fk:<table>.<col>` | match the target PK type |
270
+ | JSON / key-value block | `json` | |
271
+
272
+ Rules carried from the catalog:
273
+ - `string` always needs a length; `decimal` always needs precision,scale.
274
+ - A `*` / "required" marker → add `notnull`.
275
+ - A select with fixed options → `checks: [{ field, in: [...] }]`, options
276
+ lowercased/normalized to storage values.
277
+ - FK uses dot notation `fk:<table>.<column>`; also add the `belongsTo` relation
278
+ with `onDelete` (default `restrict`; confirm).
279
+
280
+ ---
281
+
282
+ ## Step 4 — Add structural scaffolding
283
+
284
+ A design rarely shows these, but a RESTForge table needs them:
285
+
286
+ - **Primary key** — add `<entity>_id` as `string:36 pk` (UUID style) or
287
+ `integer pk` (serial style). Confirm the convention with the user/project.
288
+ - **Audit columns** — add the four standard audit columns
289
+ (`created_at`, `created_by`, `updated_at`, `updated_by`) unless this is a pure
290
+ lookup table. "Created On" in the design maps here.
291
+ - **Soft-delete** — only if the design shows a recycle bin / "restore" / archive
292
+ affordance. Then apply the soft-delete contract (PostgreSQL only).
293
+
294
+ ---
295
+
296
+ ## Step 5 — Validate and confirm
297
+
298
+ 1. Write the draft SDF (`defineModel`) using only catalog options.
299
+ 2. Run `codegen_dbschema_validate`.
300
+ 3. Present the **confirm list** to the user before migrating:
301
+ - every guessed `string` length and `decimal` precision,
302
+ - every enum option set,
303
+ - every FK target and its `onDelete`,
304
+ - PK convention (uuid vs serial),
305
+ - any "derived" column excluded from the schema (state it explicitly so the
306
+ omission is intentional, not silent).
307
+
308
+ Never call `codegen_dbschema_migrate`/`apply` on a design-derived SDF without
309
+ this confirmation.
310
+
311
+ ---
312
+
313
+ ## SQL DDL source — direct translation path
314
+
315
+ Applies when the source is `CREATE TABLE` statements (a `.sql` file or a
316
+ pasted script), with or without a live database to run it against. This is
317
+ the lowest-inference path: the DDL already states every type and constraint
318
+ explicitly. Do not run Step 0-5 — translate what is written, syntax to
319
+ syntax, dialect to dialect-agnostic SDF.
320
+
321
+ ### Type mapping
322
+
323
+ | SQL DDL type | SDF type |
324
+ |---|---|
325
+ | `VARCHAR(n)`, `CHARACTER VARYING(n)`, bounded `CHAR(n)` | `string:n` |
326
+ | `TEXT`, `CLOB`, unbounded `NVARCHAR` | `text` |
327
+ | `INT`, `INTEGER`, `INT4` | `integer` |
328
+ | `BIGINT`, `INT8` | `bigint` |
329
+ | `DECIMAL(p,s)`, `NUMERIC(p,s)` | `decimal:p,s` |
330
+ | `BOOLEAN`, `BOOL`, legacy `TINYINT(1)` flag | `boolean` |
331
+ | `DATE` | `date` |
332
+ | `TIMESTAMP`, `TIMESTAMP WITHOUT TIME ZONE`, `DATETIME` | `timestamp` |
333
+ | `TIMESTAMPTZ`, `TIMESTAMP WITH TIME ZONE` | `timestamptz` (PostgreSQL only; on another target dialect stop and ask whether `timestamp` is acceptable) |
334
+ | `TIME` | `time` (not supported on Oracle) |
335
+ | `UUID`, `UNIQUEIDENTIFIER` | `uuid` |
336
+ | `JSON`, `JSONB` | `json` |
337
+
338
+ ### Constraint mapping
339
+
340
+ | SQL DDL | SDF |
341
+ |---|---|
342
+ | `PRIMARY KEY` | `pk` |
343
+ | `NOT NULL` | `notnull` |
344
+ | `UNIQUE` | `unique` |
345
+ | non-unique `INDEX`/`KEY` | `index` |
346
+ | `DEFAULT value` | `default:value` — same quoting rule as the catalog: string quoted, numeric/boolean raw, SQL constant bare, function e.g. `now()` |
347
+ | `CHECK (col IN (...))` | `checks: [{ field, in: [...] }]` |
348
+ | `CHECK (col =/!=/>/>=/</<= value)` | `checks: [{ field, eq/neq/gt/gte/lt/lte: value }]` |
349
+ | composite `PRIMARY KEY`/`UNIQUE` (multiple columns) | see `composite-primary-key.md` / `composite-unique.md` in the handbook catalog — do not improvise the syntax |
350
+
351
+ ### Foreign keys — shorthand vs `relations`
352
+
353
+ Apply the same mutual-exclusivity rule as the catalog (`foreign-keys.md`):
354
+ - `FOREIGN KEY ... REFERENCES table(col)` with no `ON DELETE`/`ON UPDATE`
355
+ clause (or only the dialect default) → `fk:table.col` shorthand.
356
+ - `FOREIGN KEY ... REFERENCES table(col) ON DELETE x ON UPDATE y` with an
357
+ explicit, non-default action → a `relations` entry with that `onDelete`/
358
+ `onUpdate`. Do NOT also write the `fk:` shorthand on the same field — the
359
+ catalog rejects having both.
360
+ - The reference column is always the **actual column name** named in
361
+ `REFERENCES table(column)` — this is given explicitly in DDL, so there is no
362
+ `.id`-guessing risk here (unlike inferring FK targets from a UI dropdown).
363
+
364
+ ### What NOT to add
365
+
366
+ Unlike the HTML/Image path, DDL is already a deliberate, complete spec from a
367
+ real system — do not apply Step 4's scaffolding defaults on top of it:
368
+ - Do **not** auto-add audit columns (`created_at`/`created_by`/`updated_at`/
369
+ `updated_by`) if the DDL doesn't have them. Their absence is the source
370
+ system's actual design, not a gap to fill.
371
+ - Do **not** convert the primary key strategy. If the DDL uses
372
+ `SERIAL`/`AUTO_INCREMENT`/`GENERATED ALWAYS AS IDENTITY` (integer PK), keep
373
+ `integer pk` — do not silently switch it to a `string:36` UUID PK because
374
+ that happens to be the convention used elsewhere.
375
+
376
+ ### Confirm-list additions specific to DDL
377
+
378
+ - If the DDL already has `is_deleted`/`deleted_at`/`deleted_by` columns, this
379
+ is a soft-delete table — set `softDelete.enabled: true` and verify the
380
+ target dialect is PostgreSQL (Phase 1 only); flag a conflict explicitly if
381
+ the DDL's dialect is MySQL/Oracle/SQLite.
382
+ - If the DDL's original dialect differs from the project's target dialect
383
+ (e.g. migrating MySQL DDL into a PostgreSQL project), call out type/storage
384
+ differences (e.g. MySQL boolean-as-`TINYINT(1)`) — the SDF type stays the
385
+ dialect-agnostic logical type (`boolean`), the platform handles physical
386
+ storage per target dialect.
387
+ - Named constraints in the source DDL (`pk_xxx`, `fk_xxx`) are not preserved —
388
+ RESTForge generates its own deterministic constraint names on migrate. Note
389
+ this only matters if something outside RESTForge depends on the original
390
+ constraint names.
391
+
392
+ ---
393
+
394
+ ## Worked example — categories.html
395
+
396
+ Design signals: table columns (Category, No of Items, Created On, Status,
397
+ Actions); Add/Edit form (Category Image *, Category Name *); filter (Status:
398
+ Active / Inactive).
399
+
400
+ Classification:
401
+ - `Category Name` → stored, required → `string:255 notnull`.
402
+ - `Category Image` → file upload → `string:500` (path/URL).
403
+ - `Status` → enum (Active/Inactive) → `string:20 default:'active'` + check.
404
+ - `No of Items` → **derived** (count of `items` with FK to category) → excluded
405
+ from `fields`; expose later via aggregate query or `hasMany` count.
406
+ - `Created On` → audit → `created_at`.
407
+ - `Actions` → UI only → ignored.
408
+
409
+ Resulting draft SDF:
410
+
411
+ ```js
412
+ defineModel("category", {
413
+ fields: {
414
+ category_id: "string:36 pk",
415
+ category_name: "string:255 notnull",
416
+ image_url: "string:500",
417
+ status: "string:20 notnull default:'active'",
418
+ created_at: "timestamp default:now()",
419
+ created_by: "string:100",
420
+ updated_at: "timestamp",
421
+ updated_by: "string:100"
422
+ },
423
+ checks: [
424
+ { field: "status", in: ["active", "inactive"] }
425
+ ]
426
+ })
427
+ ```
428
+
429
+ Confirm before migrate: `category_name` length (255?), `status` option values,
430
+ PK convention (uuid vs serial), and that `No of Items` is intentionally derived,
431
+ not stored.
432
+
433
+ ---
434
+
435
+ ## Worked example — items.html (master-detail + lookup decision)
436
+
437
+ Design signals: Add Item form (Item Image *, Item Name *, Description *,
438
+ Price *, Net Price *, Category * dropdown, Tax * dropdown); two repeatable
439
+ accordion sections inside the same form — "Variations" (Size *, Price * per
440
+ row) and "Add Ons" (Name *, Price($) * per row); item cards in the list show
441
+ a Veg/Non Veg badge not present anywhere in the Add form.
442
+
443
+ This source exercises three rules the categories.html example doesn't:
444
+ FK reference correctness, the lookup-master-vs-enum decision on two fields
445
+ from the *same* form, and master-detail decomposition.
446
+
447
+ Classification:
448
+ - `Item Name`, `Description`, `Price`, `Net Price` → stored, required.
449
+ - `Item Image` → file upload → `string:255`.
450
+ - `Category *` → **FK to a master table**. Decisive signal: a dedicated
451
+ `categories.html` admin page exists for it elsewhere in the same design set.
452
+ → `category_id string:36 fk:category.category_id notnull` — the target
453
+ column is `category.category_id` (the table's actual PK), not `category.id`.
454
+ - `Tax *` → **FK to a master table**, same reasoning — a dedicated
455
+ `tax-settings.html` page exists. → `tax_id string:36 fk:tax.tax_id notnull`.
456
+ The `tax` table itself is derived separately from its own admin page (title,
457
+ rate, tax type), not invented here.
458
+ - `Veg / Non Veg` badge → **enum**, even though it appears only in the list,
459
+ not the Add form. It's a small, permanent, intrinsic set with no admin page
460
+ of its own (contrast with `Tax`, which looks similar — a fixed-looking
461
+ label — but does have one). → `food_type string:20 default:'non_veg'` +
462
+ `checks: [{ field: 'food_type', in: ['veg', 'non_veg'] }]`.
463
+ - "Variations" accordion (repeatable Size + Price rows) → **master-detail
464
+ child table**, not columns on `item`. One row in the design = one row in a
465
+ new `item_variation` table with `item_id` FK back to `item`.
466
+ - "Add Ons" accordion (repeatable Name + Price rows) → same pattern, a second
467
+ child table `item_addon`.
468
+
469
+ Resulting draft SDF (four tables — `tax` is shown in outline; the master
470
+ entity and both detail tables in full):
471
+
472
+ ```js
473
+ defineModel("tax", {
474
+ fields: {
475
+ tax_id: "string:36 pk",
476
+ title: "string:100 notnull",
477
+ rate: "decimal:5,2 notnull",
478
+ tax_type: "string:20 notnull default:'exclusive'",
479
+ created_at: "timestamp default:now()",
480
+ created_by: "string:100",
481
+ updated_at: "timestamp",
482
+ updated_by: "string:100"
483
+ },
484
+ checks: [
485
+ { field: "tax_type", in: ["inclusive", "exclusive"] }
486
+ ]
487
+ });
488
+
489
+ defineModel("item", {
490
+ fields: {
491
+ item_id: "string:36 pk",
492
+ category_id: "string:36 fk:category.category_id notnull",
493
+ item_name: "string:150 notnull",
494
+ description: "text notnull",
495
+ item_image: "string:255 notnull",
496
+ food_type: "string:20 notnull default:'non_veg'",
497
+ price: "decimal:10,2 notnull",
498
+ net_price: "decimal:10,2 notnull",
499
+ tax_id: "string:36 fk:tax.tax_id notnull",
500
+ created_at: "timestamp default:now()",
501
+ created_by: "string:100",
502
+ updated_at: "timestamp",
503
+ updated_by: "string:100"
504
+ },
505
+ checks: [
506
+ { field: "food_type", in: ["veg", "non_veg"] }
507
+ ]
508
+ });
509
+
510
+ defineModel("item_variation", {
511
+ fields: {
512
+ item_variation_id: "string:36 pk",
513
+ item_id: "string:36 fk:item.item_id notnull",
514
+ size_name: "string:50 notnull",
515
+ price: "decimal:10,2 notnull",
516
+ created_at: "timestamp default:now()",
517
+ created_by: "string:100",
518
+ updated_at: "timestamp",
519
+ updated_by: "string:100"
520
+ }
521
+ });
522
+
523
+ defineModel("item_addon", {
524
+ fields: {
525
+ item_addon_id: "string:36 pk",
526
+ item_id: "string:36 fk:item.item_id notnull",
527
+ addon_name: "string:100 notnull",
528
+ price: "decimal:10,2 notnull",
529
+ created_at: "timestamp default:now()",
530
+ created_by: "string:100",
531
+ updated_at: "timestamp",
532
+ updated_by: "string:100"
533
+ }
534
+ });
535
+ ```
536
+
537
+ Confirm before migrate: `item_name`/`item_image` lengths, `food_type` default
538
+ value, whether `tax` needs more fields than the admin page showed, and that
539
+ both accordions are intentionally separate tables rather than columns on
540
+ `item`.
541
+
542
+ ---
543
+
544
+ ## Worked example — Order Details (no-form fallback + joined attribute + snapshot)
545
+
546
+ Design signals: a read-only "Order Details" page — **no Add/Edit form is
547
+ present in this source**. Panels: Order Info (Date Added, Payment Method,
548
+ Status), Customer Details (Name, Email, Phone, Country, State, Address),
549
+ Order Items (Product, Qty, Unit Price → line Total), summary (Subtotal, Tax,
550
+ Shipping Rate, Grand Total).
551
+
552
+ This source exercises three rules the previous two examples don't: the
553
+ no-form fallback, the joined-attribute exclusion, and the
554
+ derived-vs-snapshot distinction — including two fields (Address vs
555
+ Name/Email/Phone) that look identical in presentation but classify oppositely.
556
+
557
+ Classification:
558
+ - **No form in this source** → state that explicitly per the Step 1 fallback,
559
+ proceed with this detail page as the best available signal rather than
560
+ silently treating it as equivalent to a form.
561
+ - `Payment Method` → **enum**, not a master table. No admin/settings page or
562
+ quick-add affordance is present anywhere in this source.
563
+ - `Status` → enum, closed permanent set (`pending`/`processing`/`completed`/
564
+ `cancelled`).
565
+ - `Customer Name`, `Email`, `Phone` → **joined attribute**. These belong to
566
+ `customer`, reachable through `customer_id`; not stored on `order`.
567
+ - `Country`, `State`, `Address` → **snapshot**, not joined — even though they
568
+ look like the same kind of "customer info" as Name/Email/Phone just above
569
+ them. The deciding question: could the customer's saved address change
570
+ later while this order must still show where it actually shipped? Yes →
571
+ must stay frozen on the order record.
572
+ - `Product`, `Qty` → relation (FK to `product`) + stored quantity, on a
573
+ master-detail child table `order_item` (one row per line, same pattern as
574
+ `item_variation` in the previous example).
575
+ - `Unit Price` (per line) and `Subtotal`/`Tax`/`Shipping Rate`/`Grand Total`
576
+ (header summary) → **snapshot**. All are computed, but must not silently
577
+ change if the product's current price changes after the order is placed.
578
+
579
+ Resulting draft SDF:
580
+
581
+ ```js
582
+ defineModel("order", {
583
+ fields: {
584
+ order_id: "string:36 pk",
585
+ order_number: "string:20 notnull unique",
586
+ customer_id: "string:36 fk:customer.customer_id notnull",
587
+ payment_method: "string:20 notnull",
588
+ status: "string:20 notnull default:'pending'",
589
+ shipping_address: "text notnull",
590
+ shipping_country: "string:100 notnull",
591
+ shipping_state: "string:100 notnull",
592
+ subtotal: "decimal:15,2 notnull",
593
+ tax_amount: "decimal:15,2 notnull default:0",
594
+ shipping_rate: "decimal:15,2 notnull default:0",
595
+ grand_total: "decimal:15,2 notnull",
596
+ created_at: "timestamp default:now()",
597
+ created_by: "string:100"
598
+ },
599
+ checks: [
600
+ { field: "payment_method", in: ["credit_card", "bank_transfer", "cash", "e_wallet"] },
601
+ { field: "status", in: ["pending", "processing", "completed", "cancelled"] }
602
+ ]
603
+ });
604
+
605
+ defineModel("order_item", {
606
+ fields: {
607
+ order_item_id: "string:36 pk",
608
+ order_id: "string:36 fk:order.order_id notnull",
609
+ product_id: "string:36 fk:product.product_id notnull",
610
+ qty: "integer notnull",
611
+ unit_price: "decimal:15,2 notnull"
612
+ }
613
+ });
614
+ ```
615
+
616
+ Confirm before migrate: `payment_method` option set (this source gave no
617
+ admin-page signal — verify with the user whether the real system manages it
618
+ as a master table instead), `order_number` format/length, PK convention, and
619
+ that Name/Email/Phone were intentionally excluded as joined while
620
+ Address/totals were intentionally kept as snapshot — these two decisions look
621
+ similar on the screen but are opposite in the schema.