@dforge-core/dforge-mcp 0.2.33 → 0.2.34

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.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,38 @@ All notable changes to `@dforge-core/dforge-mcp`. This project uses semver-ish
4
4
  `0.1.0-rc.N` pre-release tags; the published version is set at publish time via
5
5
  the release workflow, so committed `package.json` versions are placeholders.
6
6
 
7
- ## Unreleased
7
+ ## 0.2.34 — 2026-10-06
8
+
9
+ **A report has a `label` beside its `description`** (dForge-core 61ad762). The label is
10
+ the report's name — page title, breadcrumb, parameter dialog — and the description shows
11
+ under it, as a data view's does. Without a label the description is the name, as before,
12
+ and the platform now warns about it.
13
+
14
+ - `dforge_report_add` takes `report.label`, writes it ahead of `description`, and warns
15
+ when it is omitted. Its help asks for a short label plus a description.
16
+ - `dforge_module_validate` warns, with the platform's wording, about a report with no
17
+ label. The CLI's copy of the same warning is listed once.
18
+ - `dforge://schema/reports` carries `label`. Pre-staged from dForge-core; it is
19
+ re-vendored from `@dforge-core/metadata` 0.0.34 at publish, and the CLI warning needs
20
+ `@dforge-core/dforge-cli` 0.2.27.
21
+ - `dforge://reference/reports` and the build skill name reports with `label`.
22
+
23
+ **Upgrade migrations** (dForge-core 9e93062). A module may ship `migrations/<version>.sql`
24
+ for the data a new version reshapes; an upgrade runs each one above the installed version,
25
+ inside the install transaction. `dforge://docs/conventions` and the manifest reference say
26
+ when they run, how they are named, `"dForge".next_id()` and the `"admin": ">=1.21.0"`
27
+ dependency; the ship skill's version audit asks for one when a release moves data.
28
+ `dforge_module_pack` packs the whole directory, `migrations/` included.
29
+
30
+ **A dependency on a system module may name no entity.** `dforge_dependency_add` takes
31
+ `entities: []` against admin, metadata, workspace or sys-billing — a pure platform-version
32
+ gate such as `"admin": ">=1.21.0"` for upgrade migrations. It writes `"entities": {}` to
33
+ the contract and a plain version string to the manifest. Any other module still needs an
34
+ entity. `dforge://schema/deps` says the same, pre-staged from dForge-core.
35
+
36
+ - Dependencies: `@dforge-core/metadata` `^0.0.34`, `@dforge-core/dforge-cli` `^0.2.27`.
37
+
38
+ ## 0.2.33
8
39
 
9
40
  **`isView` / `viewSql` are gone** (dForge-core#1312). SQL-view entities were in the
10
41
  entity schema and the references but never built: the entity installed as an empty table,
package/dist/server.js CHANGED
@@ -31002,7 +31002,7 @@ var import_node_crypto = require("crypto");
31002
31002
  var fs2 = __toESM(require("fs"));
31003
31003
  var path2 = __toESM(require("path"));
31004
31004
 
31005
- // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.26/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31005
+ // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.27/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31006
31006
  function buildManifest(opts, moduleId) {
31007
31007
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
31008
31008
  const manifest = {
@@ -31210,9 +31210,14 @@ var SCHEMA_BINDINGS = [
31210
31210
  { fileMatch: ["logic/triggers.json"], schema: "triggers" },
31211
31211
  { fileMatch: ["logic/webhooks.json"], schema: "webhooks" },
31212
31212
  { fileMatch: ["deps/*.json"], schema: "deps" },
31213
- { fileMatch: ["seed-data/*.json"], schema: "seed-data" },
31213
+ { fileMatch: ["seed-data/*.json", "demo-data/*.json"], schema: "seed-data" },
31214
31214
  { fileMatch: ["settings.json"], schema: "settings" },
31215
- { fileMatch: ["translations/*.json"], schema: "translations" }
31215
+ { fileMatch: ["translations/*.json"], schema: "translations" },
31216
+ { fileMatch: ["domains.json"], schema: "domains" },
31217
+ { fileMatch: ["registers.json"], schema: "registers" },
31218
+ { fileMatch: ["traits.json"], schema: "traits" },
31219
+ { fileMatch: ["ui/card_layouts.json"], schema: "card-layouts" },
31220
+ { fileMatch: ["logic/stored_procedures.json"], schema: "stored-procedures" }
31216
31221
  ];
31217
31222
  function schemaEntries() {
31218
31223
  return SCHEMA_BINDINGS.map((b) => ({
@@ -31285,7 +31290,7 @@ function buildZedTasks() {
31285
31290
  var fs = __toESM(require("fs"));
31286
31291
  var path = __toESM(require("path"));
31287
31292
 
31288
- // node_modules/.pnpm/@dforge-core+metadata@0.0.33/node_modules/@dforge-core/metadata/dist/index.js
31293
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.34/node_modules/@dforge-core/metadata/dist/index.js
31289
31294
  var AggType = {
31290
31295
  Sum: "sum",
31291
31296
  Count: "count",
@@ -34597,7 +34602,7 @@ var path10 = __toESM(require("path"));
34597
34602
  var fs7 = __toESM(require("fs"));
34598
34603
  var path9 = __toESM(require("path"));
34599
34604
 
34600
- // node_modules/.pnpm/@dforge-core+metadata@0.0.33/node_modules/@dforge-core/metadata/dist/dsl/index.js
34605
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.34/node_modules/@dforge-core/metadata/dist/dsl/index.js
34601
34606
  var PUNCT3 = ["===", "!=="];
34602
34607
  var PUNCT2 = [
34603
34608
  "==",
@@ -35377,6 +35382,45 @@ var BUILTINS = [
35377
35382
  returns: "object",
35378
35383
  doc: "Load the referenced record for a FK column. Read its fields with `ref.get('field')`."
35379
35384
  },
35385
+ // Register reads. The first argument names a REGISTER (`module_cd.register_cd`,
35386
+ // declared in that module's registers.json) and not an entity, so none of these
35387
+ // carries `entityArg` — entity-code completion there would offer the wrong list.
35388
+ {
35389
+ name: "getBalance",
35390
+ signature: "getBalance(register, dims?, asOf?)",
35391
+ returns: "object",
35392
+ doc: "Balance of an accumulation register per resource. A dimension left out of `dims` is summed over, so passing none gives the register's total. `asOf` reads as of a date instead of now."
35393
+ },
35394
+ {
35395
+ name: "getTurnover",
35396
+ signature: "getTurnover(register, dims, from, to)",
35397
+ returns: "object",
35398
+ doc: "What moved through a register over an interval: `{in, out, net}` per resource. `dims` may be `null` to sum over every dimension; `from` and `to` are both required."
35399
+ },
35400
+ {
35401
+ name: "getSlice",
35402
+ signature: "getSlice(register, dims, onDate?, mode?)",
35403
+ returns: "object | null",
35404
+ doc: "One record of an information register \u2014 the latest on or before `onDate`, or `null` when there is none. Returns the record plus `_period`. Unlike a balance, a dimension may NOT be omitted: a slice is one keyed record, not a sum."
35405
+ },
35406
+ {
35407
+ name: "getAccountBalance",
35408
+ signature: "getAccountBalance(register, account, filter?, asOf?)",
35409
+ returns: "object",
35410
+ doc: "Balance of one account of an accounting register: `{debit, credit, net}` per resource. `filter` narrows by a dimension, or by an analytic kind the ACCOUNT declares \u2014 an analytic takes the key alone, or `{ entity, value }` to name one entity of a kind that is a union of several."
35411
+ },
35412
+ {
35413
+ name: "getAccountTurnover",
35414
+ signature: "getAccountTurnover(register, account, filter, from, to)",
35415
+ returns: "object",
35416
+ doc: "What moved through one account over an interval: `{debit, credit, net}` per resource. `filter` narrows as in `getAccountBalance` and may be `null`; `from` and `to` are both required."
35417
+ },
35418
+ {
35419
+ name: "getCorrespondence",
35420
+ signature: "getCorrespondence(register, debitAccount, creditAccount, filter, from, to)",
35421
+ returns: "object",
35422
+ doc: 'What moved between two accounts over an interval. Needs `lineMode: "compound"` \u2014 a classic register records the other half of an entry as an untied second row and cannot answer this. `filter` narrows by dimensions the two sides share, never by an analytic (an analytic belongs to one account and this read names two).'
35423
+ },
35380
35424
  // Messaging
35381
35425
  {
35382
35426
  name: "error",
@@ -37149,11 +37193,141 @@ ${warnings.map((w) => ` \u2022 ${w}`).join("\n")}` : void 0
37149
37193
  // src/tools/adds.ts
37150
37194
  var fs11 = __toESM(require("fs"));
37151
37195
  var path14 = __toESM(require("path"));
37196
+
37197
+ // src/tools/validate/reports.ts
37198
+ function unlabeledReportMessage(reportCd, description) {
37199
+ const desc = typeof description === "string" && description.trim() !== "" ? description : void 0;
37200
+ return `Report '${reportCd}' declares no "label", so its description names it` + (desc ? `: "${desc}".` : ".") + " Add a short label; the description then shows under it.";
37201
+ }
37202
+ function checkReports(ctx) {
37203
+ const { manifest, err, warn, fieldDefsOf, deps, isKnownEntity, reports } = ctx;
37204
+ for (const [rcode, rawReport] of Object.entries(reports)) {
37205
+ const report = rawReport ?? {};
37206
+ const where = `ui/reports.json \u2192 ${rcode}`;
37207
+ if (typeof report.label !== "string" || report.label.trim() === "") {
37208
+ warn("ui/reports.json", unlabeledReportMessage(rcode, report.description));
37209
+ }
37210
+ const declaredParams = /* @__PURE__ */ new Set();
37211
+ const checkParamDef = (loc, pcode, pdef) => {
37212
+ const p = pdef ?? {};
37213
+ if ("isRequired" in p) {
37214
+ err(
37215
+ `${where} \u2192 ${loc}.${pcode}`,
37216
+ "uses 'isRequired', which the installer does not read \u2014 the param installs as OPTIONAL. Rename it to 'required'."
37217
+ );
37218
+ }
37219
+ if ("link" in p) {
37220
+ err(
37221
+ `${where} \u2192 ${loc}.${pcode}`,
37222
+ 'puts \'link\' at the top level, where the installer does not read it \u2014 a lookup param with no `params.link` has no autocomplete. Nest it: "params": { "link": { "entity": "\u2026" } }.'
37223
+ );
37224
+ }
37225
+ if ("fieldTypeCd" in p && "domain" in p) {
37226
+ err(
37227
+ `${where} \u2192 ${loc}.${pcode}`,
37228
+ "declares both 'fieldTypeCd' and 'domain' \u2014 install rejects the pair rather than picking a winner. A domain supplies the control; drop the fieldTypeCd."
37229
+ );
37230
+ }
37231
+ };
37232
+ const reportParams = report.parameters ?? {};
37233
+ for (const [pcode, pdef] of Object.entries(reportParams)) {
37234
+ declaredParams.add(pcode);
37235
+ checkParamDef("parameters", pcode, pdef);
37236
+ }
37237
+ const datasets = report.datasets ?? {};
37238
+ for (const [dcode, ds] of Object.entries(datasets)) {
37239
+ const dsParams = ds?.params ?? {};
37240
+ for (const [pcode, pdef] of Object.entries(dsParams)) {
37241
+ declaredParams.add(pcode);
37242
+ checkParamDef(`datasets.${dcode}.params`, pcode, pdef);
37243
+ }
37244
+ }
37245
+ const attachments = report.entities;
37246
+ if (attachments === void 0) continue;
37247
+ if (!Array.isArray(attachments)) {
37248
+ err(where, "'entities' must be an array of record-report attachments.");
37249
+ continue;
37250
+ }
37251
+ const seen = /* @__PURE__ */ new Set();
37252
+ for (const raw of attachments) {
37253
+ const att = raw ?? {};
37254
+ const entityCd = att.entityCd;
37255
+ if (typeof entityCd !== "string" || entityCd.trim() === "") {
37256
+ err(where, "an entry in 'entities' is missing 'entityCd'.");
37257
+ continue;
37258
+ }
37259
+ const bare = entityCd.startsWith(`${manifest.code}.`) ? entityCd.slice(manifest.code.length + 1) : entityCd;
37260
+ if (seen.has(bare)) {
37261
+ err(
37262
+ where,
37263
+ `attaches to '${entityCd}' twice. One attachment per (entity, report) pair \u2014 merge the two 'params' maps into a single entry.`
37264
+ );
37265
+ continue;
37266
+ }
37267
+ seen.add(bare);
37268
+ if (!isKnownEntity(entityCd)) {
37269
+ err(
37270
+ where,
37271
+ `attaches to '${entityCd}', which is not a known entity (same-module, system, or a declared cross-module dependency). Qualify a foreign entity as 'module.entity' and add that module to the manifest's dependencies.`
37272
+ );
37273
+ }
37274
+ const paramMap = att.params ?? {};
37275
+ for (const [paramCd, source] of Object.entries(paramMap)) {
37276
+ if (!declaredParams.has(paramCd)) {
37277
+ err(
37278
+ where,
37279
+ `maps '${paramCd}' from '${entityCd}.${String(source)}', but '${paramCd}' is not a declared parameter of this report. Declare it in the report's 'parameters' block, or under the 'params' of the dataset that consumes it.`
37280
+ );
37281
+ continue;
37282
+ }
37283
+ if (typeof source !== "string" || source.trim() === "") {
37284
+ err(where, `maps parameter '${paramCd}' from an empty source column on '${entityCd}'.`);
37285
+ continue;
37286
+ }
37287
+ const fields = fieldDefsOf[bare];
37288
+ if (!fields) continue;
37289
+ const col = fields[source];
37290
+ if (!col) {
37291
+ err(
37292
+ where,
37293
+ `maps parameter '${paramCd}' from '${entityCd}.${source}', which is not a column of that entity.`
37294
+ );
37295
+ continue;
37296
+ }
37297
+ if (col.columnType === "S" || col.columnType === "F") {
37298
+ err(
37299
+ where,
37300
+ `maps parameter '${paramCd}' from '${entityCd}.${source}', a ${col.columnType === "S" ? "set ('S')" : "formula ('F')"} column. Map the PK, a reference ('R') column, or a bounded scalar.`
37301
+ );
37302
+ continue;
37303
+ }
37304
+ const db = typeof col.dbDatatype === "string" ? col.dbDatatype.toLowerCase() : "";
37305
+ if (col.columnType !== "R" && col.isPk !== true && /^(text|json|jsonb|bytea|uuid)$/.test(db)) {
37306
+ err(
37307
+ where,
37308
+ `maps parameter '${paramCd}' from '${entityCd}.${source}' (dbDatatype '${db}'), which is not a valid record-report parameter source. Allowed: the entity PK, a reference column, or a bounded scalar (number, date/datetime, bool, dropdown/radio/flags code).`
37309
+ );
37310
+ }
37311
+ }
37312
+ }
37313
+ if (seen.size > 0 && !deps.has("metadata")) {
37314
+ warn(
37315
+ where,
37316
+ `declares record-report attachments but the manifest has no 'metadata' dependency. The 'entity_report' table ships with the metadata system module \u2014 add "metadata": ">=1.5.0" to dependencies so install fails loudly on a platform without it, rather than the attachment quietly doing nothing.`
37317
+ );
37318
+ }
37319
+ }
37320
+ }
37321
+
37322
+ // src/tools/adds.ts
37152
37323
  var reportAddSchema = {
37153
37324
  moduleDir: external_exports.string(),
37154
37325
  code: external_exports.string().regex(/^[a-z][a-z0-9_]*$/).describe("Report code (keys ui/reports.json)."),
37155
37326
  report: external_exports.object({
37156
- description: external_exports.string(),
37327
+ label: external_exports.string().optional().describe(
37328
+ "The report's name: page title, breadcrumb and parameter dialog. Keep it short ('Trial Balance'). Omitted, the description names the report and pack/validate/install warn."
37329
+ ),
37330
+ description: external_exports.string().describe("What the report shows, under its label on the page ('Debit and credit totals per account for a period')."),
37157
37331
  layout: external_exports.record(external_exports.string(), external_exports.unknown()),
37158
37332
  datasets: external_exports.record(external_exports.string(), external_exports.unknown()),
37159
37333
  parameters: external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe(
@@ -37169,7 +37343,7 @@ var reportAddSchema = {
37169
37343
  "RECORD-REPORT attachments \u2014 entities this report can be opened FROM, so it appears on the record's toolbar with the record's values feeding its params. Each entry: { entityCd, params: { <reportParamCd>: <sourceColumnOnThatEntity> }, orderNum? }. NOTE `params` here is the MAPPING (param \u2192 column), not a param declaration. Qualify a cross-module entityCd ('parties.party') and declare that module as a dependency. Source columns: the PK, a reference ('R') column, or a bounded scalar \u2014 never text/json/file. Requires 'metadata': '>=1.5.0' in the manifest."
37170
37344
  )
37171
37345
  }).passthrough().describe(
37172
- "Full report spec per reports.schema.json: { description, layout: { panels: [...] }, datasets: { code: {...} }, parameters?, entities?, reloadInterval? }. PARAMS ARE REPORT-SCOPED: declare them in the top-level `parameters` block, or as shorthand under `datasets.<cd>.params` when exactly one dataset uses the param \u2014 the installer merges both into the report's single param set, report level winning on a collision. A param several datasets use belongs at report level; there is no meaningful dataset to attribute it to. Within a param, use `required` (not `isRequired`) and nest a lookup's binding as `params: { link: { entity } }` (not a top-level `link`)."
37346
+ "Full report spec per reports.schema.json: { label, description, layout: { panels: [...] }, datasets: { code: {...} }, parameters?, entities?, reloadInterval? }. PARAMS ARE REPORT-SCOPED: declare them in the top-level `parameters` block, or as shorthand under `datasets.<cd>.params` when exactly one dataset uses the param \u2014 the installer merges both into the report's single param set, report level winning on a collision. A param several datasets use belongs at report level; there is no meaningful dataset to attribute it to. Within a param, use `required` (not `isRequired`) and nest a lookup's binding as `params: { link: { entity } }` (not a top-level `link`)."
37173
37347
  )
37174
37348
  };
37175
37349
  function reportAdd(args) {
@@ -37178,11 +37352,16 @@ function reportAdd(args) {
37178
37352
  if (Object.prototype.hasOwnProperty.call(reports, args.code)) {
37179
37353
  throw new Error(`Report '${args.code}' already exists in ui/reports.json.`);
37180
37354
  }
37181
- reports[args.code] = args.report;
37182
- return makeResult(`Added report '${args.code}'.`, {
37183
- [rel(paths.root, paths.reports)]: jsonText(reports),
37184
- "manifest.json": jsonText(withTodayStamp(manifest))
37185
- });
37355
+ const { label, ...rest } = args.report;
37356
+ reports[args.code] = label?.trim() ? { label, ...rest } : rest;
37357
+ return makeResult(
37358
+ `Added report '${args.code}'.`,
37359
+ {
37360
+ [rel(paths.root, paths.reports)]: jsonText(reports),
37361
+ "manifest.json": jsonText(withTodayStamp(manifest))
37362
+ },
37363
+ label?.trim() ? void 0 : unlabeledReportMessage(args.code, args.report.description)
37364
+ );
37186
37365
  }
37187
37366
  var settingAddSchema = {
37188
37367
  moduleDir: external_exports.string(),
@@ -37368,13 +37547,13 @@ function diagramAdd(args) {
37368
37547
  }
37369
37548
  );
37370
37549
  }
37371
- var SYSTEM_MODULES = /* @__PURE__ */ new Set(["admin", "metadata", "workspace"]);
37550
+ var SYSTEM_MODULES = /* @__PURE__ */ new Set(["admin", "metadata", "workspace", "sys-billing"]);
37372
37551
  var dependencyAddSchema = {
37373
37552
  moduleDir: external_exports.string(),
37374
37553
  moduleCode: external_exports.string().regex(/^[a-z][a-z0-9_-]*$/).describe("Module to depend on."),
37375
37554
  version: external_exports.string().default(">=0.1.0").describe("Semver range."),
37376
- entities: external_exports.array(external_exports.string()).min(1).describe(
37377
- "The provider entities this module consumes, e.g. ['party']. Required: every dependency needs a deps/<module>.json contract naming at least one entity, and a dependency you cannot name a consumed entity for should not be declared at all. Also narrows the manifest dep to the object form `{ version, entities }`."
37555
+ entities: external_exports.array(external_exports.string()).describe(
37556
+ "The provider entities this module consumes, e.g. ['party']. Every dependency needs a deps/<module>.json contract naming at least one entity, and a dependency you cannot name a consumed entity for should not be declared at all. Narrows the manifest dep to the object form `{ version, entities }`. Exception: against a system module (admin, metadata, workspace, sys-billing) pass [] for a pure platform-version gate, e.g. admin >=1.21.0 for upgrade migrations; the contract then declares \"entities\": {} and the manifest dep stays a plain version string."
37378
37557
  ),
37379
37558
  use: external_exports.array(external_exports.string()).optional().describe(
37380
37559
  "Provenance tokens recorded against each declared entity: '<kind>:<symbol>' where kind is ref|extends|formula|action|view|report|print|role, e.g. 'ref:invoice.customer'. 'extends' must be fully qualified ('extends:parties.party'). Defaults to a placeholder you must refine."
@@ -37394,6 +37573,13 @@ function dependencyAdd(args) {
37394
37573
  `Dependency on '${args.moduleCode}' already exists. Edit manifest.json directly to change the version.`
37395
37574
  );
37396
37575
  }
37576
+ const isSystem = SYSTEM_MODULES.has(args.moduleCode);
37577
+ const gateOnly = args.entities.length === 0;
37578
+ if (gateOnly && !isSystem) {
37579
+ throw new Error(
37580
+ `Name at least one '${args.moduleCode}' entity this module consumes. Only a system module (${[...SYSTEM_MODULES].join(", ")}) may be depended on with no entity, as a platform-version gate.`
37581
+ );
37582
+ }
37397
37583
  const contractPath = path14.join(paths.root, "deps", `${args.moduleCode}.json`);
37398
37584
  if (fs11.existsSync(contractPath)) {
37399
37585
  throw new Error(
@@ -37404,10 +37590,10 @@ function dependencyAdd(args) {
37404
37590
  ...manifest,
37405
37591
  dependencies: {
37406
37592
  ...deps,
37407
- [args.moduleCode]: { version: args.version, entities: args.entities }
37593
+ [args.moduleCode]: gateOnly ? args.version : { version: args.version, entities: args.entities }
37408
37594
  }
37409
37595
  });
37410
- const placeholderUse = !args.use || args.use.length === 0;
37596
+ const placeholderUse = !gateOnly && (!args.use || args.use.length === 0);
37411
37597
  const use = placeholderUse ? [`ref:${manifest.code}`] : args.use;
37412
37598
  const contract = {
37413
37599
  module: args.moduleCode,
@@ -37425,26 +37611,28 @@ function dependencyAdd(args) {
37425
37611
  `deps/${args.moduleCode}.json was written with a placeholder provenance token 'ref:${manifest.code}'. Replace it with the real usage (e.g. 'ref:<my_entity>.<my_column>', 'extends:${args.moduleCode}.<entity>', 'role:<role_cd>') \u2014 it is the record of WHY the dependency exists.`
37426
37612
  );
37427
37613
  }
37428
- if (!args.pks) {
37614
+ if (!gateOnly && !args.pks) {
37429
37615
  warnings.push(
37430
37616
  `PKs were guessed as '<entity>_id'. Check them against ${args.moduleCode}'s entity files \u2014 a wrong pk fails the install-time satisfaction check.`
37431
37617
  );
37432
37618
  }
37433
- if (SYSTEM_MODULES.has(args.moduleCode)) {
37619
+ if (isSystem && !gateOnly) {
37434
37620
  warnings.push(
37435
37621
  `'${args.moduleCode}' is a system module \u2014 always provisioned, so this dependency is only meaningful as a minimum-platform-version gate. If you are not gating a platform feature, drop it.`
37436
37622
  );
37437
37623
  }
37438
- warnings.push(
37439
- `Declare the columns you actually read, write or join on under each entity's 'columns' \u2014 see dforge://schema/deps.`
37440
- );
37624
+ if (!gateOnly) {
37625
+ warnings.push(
37626
+ `Declare the columns you actually read, write or join on under each entity's 'columns' \u2014 see dforge://schema/deps.`
37627
+ );
37628
+ }
37441
37629
  return makeResult(
37442
- `Added dependency on '${args.moduleCode}' (${args.version}) for entities [${args.entities.join(", ")}], with the required deps/${args.moduleCode}.json contract.`,
37630
+ gateOnly ? `Added platform-version gate on '${args.moduleCode}' (${args.version}), with a deps/${args.moduleCode}.json contract that declares no entity.` : `Added dependency on '${args.moduleCode}' (${args.version}) for entities [${args.entities.join(", ")}], with the required deps/${args.moduleCode}.json contract.`,
37443
37631
  {
37444
37632
  "manifest.json": jsonText(newManifest),
37445
37633
  [`deps/${args.moduleCode}.json`]: jsonText(contract)
37446
37634
  },
37447
- warnings.join("\n")
37635
+ warnings.length ? warnings.join("\n") : void 0
37448
37636
  );
37449
37637
  }
37450
37638
 
@@ -38265,124 +38453,6 @@ function checkDiagrams(ctx) {
38265
38453
  }
38266
38454
  }
38267
38455
 
38268
- // src/tools/validate/reports.ts
38269
- function checkReports(ctx) {
38270
- const { manifest, err, warn, fieldDefsOf, deps, isKnownEntity, reports } = ctx;
38271
- for (const [rcode, rawReport] of Object.entries(reports)) {
38272
- const report = rawReport ?? {};
38273
- const where = `ui/reports.json \u2192 ${rcode}`;
38274
- const declaredParams = /* @__PURE__ */ new Set();
38275
- const checkParamDef = (loc, pcode, pdef) => {
38276
- const p = pdef ?? {};
38277
- if ("isRequired" in p) {
38278
- err(
38279
- `${where} \u2192 ${loc}.${pcode}`,
38280
- "uses 'isRequired', which the installer does not read \u2014 the param installs as OPTIONAL. Rename it to 'required'."
38281
- );
38282
- }
38283
- if ("link" in p) {
38284
- err(
38285
- `${where} \u2192 ${loc}.${pcode}`,
38286
- 'puts \'link\' at the top level, where the installer does not read it \u2014 a lookup param with no `params.link` has no autocomplete. Nest it: "params": { "link": { "entity": "\u2026" } }.'
38287
- );
38288
- }
38289
- if ("fieldTypeCd" in p && "domain" in p) {
38290
- err(
38291
- `${where} \u2192 ${loc}.${pcode}`,
38292
- "declares both 'fieldTypeCd' and 'domain' \u2014 install rejects the pair rather than picking a winner. A domain supplies the control; drop the fieldTypeCd."
38293
- );
38294
- }
38295
- };
38296
- const reportParams = report.parameters ?? {};
38297
- for (const [pcode, pdef] of Object.entries(reportParams)) {
38298
- declaredParams.add(pcode);
38299
- checkParamDef("parameters", pcode, pdef);
38300
- }
38301
- const datasets = report.datasets ?? {};
38302
- for (const [dcode, ds] of Object.entries(datasets)) {
38303
- const dsParams = ds?.params ?? {};
38304
- for (const [pcode, pdef] of Object.entries(dsParams)) {
38305
- declaredParams.add(pcode);
38306
- checkParamDef(`datasets.${dcode}.params`, pcode, pdef);
38307
- }
38308
- }
38309
- const attachments = report.entities;
38310
- if (attachments === void 0) continue;
38311
- if (!Array.isArray(attachments)) {
38312
- err(where, "'entities' must be an array of record-report attachments.");
38313
- continue;
38314
- }
38315
- const seen = /* @__PURE__ */ new Set();
38316
- for (const raw of attachments) {
38317
- const att = raw ?? {};
38318
- const entityCd = att.entityCd;
38319
- if (typeof entityCd !== "string" || entityCd.trim() === "") {
38320
- err(where, "an entry in 'entities' is missing 'entityCd'.");
38321
- continue;
38322
- }
38323
- const bare = entityCd.startsWith(`${manifest.code}.`) ? entityCd.slice(manifest.code.length + 1) : entityCd;
38324
- if (seen.has(bare)) {
38325
- err(
38326
- where,
38327
- `attaches to '${entityCd}' twice. One attachment per (entity, report) pair \u2014 merge the two 'params' maps into a single entry.`
38328
- );
38329
- continue;
38330
- }
38331
- seen.add(bare);
38332
- if (!isKnownEntity(entityCd)) {
38333
- err(
38334
- where,
38335
- `attaches to '${entityCd}', which is not a known entity (same-module, system, or a declared cross-module dependency). Qualify a foreign entity as 'module.entity' and add that module to the manifest's dependencies.`
38336
- );
38337
- }
38338
- const paramMap = att.params ?? {};
38339
- for (const [paramCd, source] of Object.entries(paramMap)) {
38340
- if (!declaredParams.has(paramCd)) {
38341
- err(
38342
- where,
38343
- `maps '${paramCd}' from '${entityCd}.${String(source)}', but '${paramCd}' is not a declared parameter of this report. Declare it in the report's 'parameters' block, or under the 'params' of the dataset that consumes it.`
38344
- );
38345
- continue;
38346
- }
38347
- if (typeof source !== "string" || source.trim() === "") {
38348
- err(where, `maps parameter '${paramCd}' from an empty source column on '${entityCd}'.`);
38349
- continue;
38350
- }
38351
- const fields = fieldDefsOf[bare];
38352
- if (!fields) continue;
38353
- const col = fields[source];
38354
- if (!col) {
38355
- err(
38356
- where,
38357
- `maps parameter '${paramCd}' from '${entityCd}.${source}', which is not a column of that entity.`
38358
- );
38359
- continue;
38360
- }
38361
- if (col.columnType === "S" || col.columnType === "F") {
38362
- err(
38363
- where,
38364
- `maps parameter '${paramCd}' from '${entityCd}.${source}', a ${col.columnType === "S" ? "set ('S')" : "formula ('F')"} column. Map the PK, a reference ('R') column, or a bounded scalar.`
38365
- );
38366
- continue;
38367
- }
38368
- const db = typeof col.dbDatatype === "string" ? col.dbDatatype.toLowerCase() : "";
38369
- if (col.columnType !== "R" && col.isPk !== true && /^(text|json|jsonb|bytea|uuid)$/.test(db)) {
38370
- err(
38371
- where,
38372
- `maps parameter '${paramCd}' from '${entityCd}.${source}' (dbDatatype '${db}'), which is not a valid record-report parameter source. Allowed: the entity PK, a reference column, or a bounded scalar (number, date/datetime, bool, dropdown/radio/flags code).`
38373
- );
38374
- }
38375
- }
38376
- }
38377
- if (seen.size > 0 && !deps.has("metadata")) {
38378
- warn(
38379
- where,
38380
- `declares record-report attachments but the manifest has no 'metadata' dependency. The 'entity_report' table ships with the metadata system module \u2014 add "metadata": ">=1.5.0" to dependencies so install fails loudly on a platform without it, rather than the attachment quietly doing nothing.`
38381
- );
38382
- }
38383
- }
38384
- }
38385
-
38386
38456
  // src/tools/module-validate.ts
38387
38457
  var moduleValidateSchema = {
38388
38458
  moduleDir: external_exports.string().describe("Path to the module root. Run this after authoring and before dforge_module_pack.")
@@ -38420,7 +38490,9 @@ function moduleValidate(args, cli) {
38420
38490
  if (res.report.error) err("cli: package", res.report.error);
38421
38491
  for (const c of res.report.checks) if (!c.ok) err(`cli: ${c.name}`, c.message ?? "failed");
38422
38492
  for (const w of res.report.warnings) {
38423
- const dup = issues.some((i) => i.where === w.where && i.message.includes("toString") && w.message.includes("toString"));
38493
+ const dup = issues.some(
38494
+ (i) => i.where === w.where && i.message.includes("toString") && w.message.includes("toString") || i.level === "warning" && i.message === w.message
38495
+ );
38424
38496
  if (!dup) warn(w.where, w.message);
38425
38497
  }
38426
38498
  }
@@ -38792,7 +38864,7 @@ var resources = [
38792
38864
  schema(
38793
38865
  "reports",
38794
38866
  "Reports JSON schema",
38795
- "JSON Schema for ui/reports.json \u2014 map of report code \u2192 { description, layout.panels[], datasets, parameters? }. Datasets and panel queries reuse the filter shape from data_views."
38867
+ "JSON Schema for ui/reports.json \u2014 map of report code \u2192 { label, description, layout.panels[], datasets, parameters? }. Datasets and panel queries reuse the filter shape from data_views."
38796
38868
  ),
38797
38869
  schema(
38798
38870
  "stored-procedures",
@@ -38825,7 +38897,7 @@ var resources = [
38825
38897
  reference("action-dsl", "Action DSL grammar + 'Registering the action' (ui/actions.json: entityCode/executionMode/script/isAsync/bi- icon)."),
38826
38898
  reference("filters", "Canonical filter shape for views, folders, and reports."),
38827
38899
  reference("security", "Security roles + rights matrix: 'rights' key, SIUDC for entities, E for actions/reports/folders."),
38828
- reference("reports", "Reports: layout panels + datasets (Query or Stored Procedure)."),
38900
+ reference("reports", "Reports: label + description, layout panels + datasets (Query or Stored Procedure)."),
38829
38901
  reference("settings", "Module settings shape."),
38830
38902
  reference("jobs", "Scheduled jobs: cron, timeout, jobClass, and the no-record-context constraint."),
38831
38903
  reference("number-sequences", "Number sequences for reference numbers / codes."),
@@ -39188,7 +39260,7 @@ server.registerTool(
39188
39260
  "dforge_report_add",
39189
39261
  {
39190
39262
  title: "Add a report",
39191
- description: "PHASE 3: Add a report to ui/reports.json. Read dforge://schema/reports for the layout/datasets/parameters shape.",
39263
+ description: "PHASE 3: Add a report to ui/reports.json. Give it a short `label` (its name: page title, breadcrumb, parameter dialog) and a `description` of what it shows, which appears under the label; without a label the description becomes the title and pack/validate/install warn. Read dforge://schema/reports for the layout/datasets/parameters shape.",
39192
39264
  inputSchema: { ...reportAddSchema, ...applyInput }
39193
39265
  },
39194
39266
  envelope(reportAdd)
@@ -39251,7 +39323,7 @@ server.registerTool(
39251
39323
  "dforge_dependency_add",
39252
39324
  {
39253
39325
  title: "Add a module dependency",
39254
- description: "Add a dependency on another dForge module. Writes BOTH halves the platform requires: the manifest.json entry and the deps/<module>.json contract declaring what this module consumes \u2014 a manifest dependency without a contract fails validate, pack and install. Name the consumed entities in `entities`; do not declare a dependency you cannot name one for, and do not depend on the system modules (admin, metadata, workspace) except to gate a minimum platform version.",
39326
+ description: "Add a dependency on another dForge module. Writes BOTH halves the platform requires: the manifest.json entry and the deps/<module>.json contract declaring what this module consumes \u2014 a manifest dependency without a contract fails validate, pack and install. Name the consumed entities in `entities`; do not declare a dependency you cannot name one for, and do not depend on the system modules (admin, metadata, workspace, sys-billing) except to gate a minimum platform version \u2014 a gate for a feature that introduced no entity passes `entities: []`.",
39255
39327
  inputSchema: { ...dependencyAddSchema, ...applyInput }
39256
39328
  },
39257
39329
  envelope(dependencyAdd)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.33",
3
+ "version": "0.2.34",
4
4
  "description": "MCP server for dForge module authoring. Exposes 35 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/iash44/dForge-core",
@@ -26,8 +26,8 @@
26
26
  "node": ">=18"
27
27
  },
28
28
  "dependencies": {
29
- "@dforge-core/dforge-cli": "^0.2.26",
30
- "@dforge-core/metadata": "^0.0.33",
29
+ "@dforge-core/dforge-cli": "^0.2.27",
30
+ "@dforge-core/metadata": "^0.0.34",
31
31
  "@modelcontextprotocol/sdk": "^1.30.0",
32
32
  "zod": "^4.4.3"
33
33
  },
@@ -71,6 +71,8 @@ Use the CRM sample module (`modules/crm/`) as a reference implementation.
71
71
  ├── /print_templates/ # HTML/CSS print templates (optional)
72
72
  │ ├── invoice.html
73
73
  │ └── invoice.css
74
+ ├── /migrations/ # Upgrade data scripts, {version}.sql (optional)
75
+ │ └── 0.2.0.sql
74
76
  └── /files/ # Module static files (optional) — README, docs
75
77
  └── README.md
76
78
  ```
@@ -964,6 +966,24 @@ Use semantic versioning: `MAJOR.MINOR.PATCH`
964
966
  1. **`version`**: Overall module version (includes all changes)
965
967
  2. **`dbSchemaVersion`**: Database schema version (only bumped for schema changes)
966
968
 
969
+ ### Upgrade Migrations (`migrations/`)
970
+
971
+ A table or column change needs no script: the installer generates the DDL. A script is for the DATA a new version reshapes — filling a new column from an old one, moving rows to a new table, dropping a table the new version no longer declares (the platform never drops one on its own).
972
+
973
+ ```
974
+ migrations/
975
+ └── 0.11.0.sql # runs on an upgrade from below 0.11.0 to 0.11.0 or later
976
+ ```
977
+
978
+ - **Name** — the module `version` the file upgrades to, `MAJOR.MINOR.PATCH.sql`. Keyed by `version`, not `dbSchemaVersion`. A misnamed file, or one newer than the manifest `version`, fails the package at load.
979
+ - **When** — on an upgrade only: every file above the installed version and no higher than the package's, in ascending order. A fresh install or a same-version reinstall runs none.
980
+ - **Where** — inside the install transaction, after the new tables and columns exist and before `NOT NULL` is applied or a register's `totals` cache is rebuilt. A table the new version removed is still there to read. A failing script rolls the upgrade back and the module stays on the old version; refuse unconvertible data with `RAISE EXCEPTION '…'`, which the operator sees.
981
+ - **Ids** — `"dForge".next_id()` mints a row id in SQL. Declare `"admin": ">=1.21.0"` in `dependencies`; that is also what guarantees a platform that runs migrations.
982
+ - **Locks** — a script writing a posted document or its register journal wraps the writes in `SELECT set_config('registry.posting', 'true', true);` … `'false'`, so the post locks stand aside. Period locks do not; reopen a closed period and close it again.
983
+ - A `kind: "pack"` module cannot carry migrations.
984
+
985
+ Reference: `modules/gl/migrations/0.11.0.sql` in dForge-core.
986
+
967
987
  ---
968
988
 
969
989
  ## Installation & Reinstallation
@@ -18,11 +18,16 @@
18
18
  },
19
19
  "entities": {
20
20
  "type": "object",
21
- "description": "Consumed provider entities, keyed by entity code (e.g. 'party').",
22
- "minProperties": 1,
21
+ "description": "Consumed provider entities, keyed by entity code (e.g. 'party'). At least one, except against a system module (admin, metadata, workspace, sys-billing), where {} declares a pure platform-version gate for a feature that introduced no entity.",
23
22
  "additionalProperties": { "$ref": "#/definitions/entityContract" }
24
23
  }
25
24
  },
25
+ "if": {
26
+ "not": { "properties": { "module": { "enum": ["admin", "metadata", "workspace", "sys-billing"] } }, "required": ["module"] }
27
+ },
28
+ "then": {
29
+ "properties": { "entities": { "minProperties": 1 } }
30
+ },
26
31
  "definitions": {
27
32
  "entityContract": {
28
33
  "type": "object",
@@ -2,7 +2,7 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "https://dforge.dev/schemas/domains.schema.json",
4
4
  "title": "dForge Column Domains",
5
- "description": "domains.json — reusable, named semantic types. A domain bundles a base datatype, a control, sizing and a shared option list under one code; columns reference it via \"domain\": \"module_cd.domain_cd\" instead of restating the definition. Structural fields are materialized onto each column at install; options stay on the domain and resolve at metadata-load time, so a shared list is authored and translated exactly once.",
5
+ "description": "domains.json — reusable, named semantic types. A domain bundles a base datatype, a control, sizing and a shared option list under one code; columns reference it via \"domain\": \"domain_cd\" (this module's own) or \"module_cd.domain_cd\" (another module's) instead of restating the definition. Structural fields are materialized onto each column at install; options stay on the domain and resolve at metadata-load time, so a shared list is authored and translated exactly once.",
6
6
  "type": "object",
7
7
  "additionalProperties": {
8
8
  "$ref": "#/$defs/domain"