Files
dashlibstorage/generate-api-doc.js
2026-10-18 20:48:47 +02:00

444 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const storeDir = join(__dirname, 'src', 'store');
const outputPath = join(__dirname, 'API.md');
// Modules that are exposed on the LibDashStore instance (from src/index.js).
// Order here controls the order of sections in the generated document.
const MODULE_ORDER = [
'companyuser',
'company',
'category',
'article',
'lang',
'tax',
'unit',
'companysetting',
'brand',
'customergroup',
'entity',
'entityaddress',
'entitycontact',
'supplier',
'articlesupplier',
'delivery',
'quote',
'order',
'shipment',
'shipping',
'manifestattachment',
'ledger',
'warehouse',
'stock',
'posterminal',
'printer',
'google_auth',
'pic',
'numbers',
'propertyname',
'articleproperty',
'attributename',
'attributevalue',
'articleattributevalue',
'labeltemplate',
'healthcheck',
];
// Files that are internal helpers and should not appear as public API entries.
const INTERNAL_FILES = new Set([
'request.js',
'slugify.js',
'initialize.js',
'purge.js',
'helpers.js',
'writeLines.js',
'shapeSetting.js',
'shapeUnit.js',
'ensureDefault.js',
'ensureLieferantenGroup.js',
'resolveCompanyAccess.js',
'resolveSelectedCompanyAccess.js',
'resolveCategoryAccess.js',
'resolveCustomerGroupAccess.js',
'resolveEntityAccess.js',
'resolvePicAccess.js',
'resolveArticleBySku.js',
'resolveManifestAccess.js',
'queryBySku.js',
'queryCategoryArticles.js',
'getCategoryPosterminalIds.js',
'nextSku.js',
'nextNumber.js',
'peekNumber.js',
'shape.js',
'createStore.js',
'manifestLines.js',
'runner.js',
'cron.js',
'index.js',
]);
/**
* Strip the outer braces from a JSDoc type (e.g. `{number}` -> `number`),
* preserving nested braces inside generics.
* @param {string} type
* @returns {string}
*/
function stripBraces(type) {
if (type.startsWith('{') && type.endsWith('}')) {
return type.slice(1, -1);
}
return type;
}
/**
* Extract the leading JSDoc block from a source string.
* @param {string} source
* @returns {{ description: string, example: string, params: Array<object>, returns: string|null, throws: string|null } | null}
*/
function extractJsDoc(source) {
const match = source.match(/\/\*\*([\s\S]*?)\*\//);
if (!match) return null;
const block = match[1];
const lines = block
.split('\n')
.map((l) => l.replace(/^\s*\*\s?/, '').replace(/^\s*\*/, ''));
const description = [];
const example = [];
const params = [];
let returns = null;
let throws = null;
let inExample = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '@example') {
inExample = true;
continue;
}
if (trimmed.startsWith('@')) {
inExample = false;
const paramMatch = trimmed.match(/^@param\s+(\{.*\})\s+(\S+)\s*[-–]?\s*(.*)$/);
if (paramMatch) {
params.push({
type: stripBraces(paramMatch[1]),
name: paramMatch[2],
description: paramMatch[3].trim(),
});
continue;
}
const returnsMatch = trimmed.match(/^@returns\s+(\{.*\})\s*[-–]?\s*(.*)$/);
if (returnsMatch) {
returns = { type: stripBraces(returnsMatch[1]), description: returnsMatch[2].trim() };
continue;
}
const throwsMatch = trimmed.match(/^@throws\s+(\{.*\})\s*[-–]?\s*(.*)$/);
if (throwsMatch) {
throws = { type: stripBraces(throwsMatch[1]), description: throwsMatch[2].trim() };
continue;
}
continue;
}
if (inExample) {
example.push(line);
} else if (trimmed) {
description.push(trimmed);
}
}
return {
description: description.join(' ').trim(),
example: example.join('\n').trim(),
params,
returns,
throws,
};
}
/**
* Recursively collect all .js files under a directory.
* @param {string} dir
* @returns {string[]} absolute file paths
*/
function collectFiles(dir) {
const out = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
out.push(...collectFiles(full));
} else if (entry.endsWith('.js')) {
out.push(full);
}
}
return out;
}
/**
* Build a map of module name -> array of { name, file, description, example }.
* @returns {Map<string, Array<object>>}
*/
function buildModules() {
const modules = new Map();
for (const file of collectFiles(storeDir)) {
const rel = file.slice(storeDir.length + 1).split('/');
const moduleName = rel[0];
const fileName = rel[rel.length - 1];
if (INTERNAL_FILES.has(fileName)) continue;
const source = readFileSync(file, 'utf8');
const doc = extractJsDoc(source);
if (!doc) continue;
const fnName = fileName.replace(/\.js$/, '');
if (!modules.has(moduleName)) {
modules.set(moduleName, []);
}
modules.get(moduleName).push({
name: fnName,
file: rel.join('/'),
description: doc.description,
example: doc.example,
params: doc.params,
returns: doc.returns,
throws: doc.throws,
});
}
for (const entries of modules.values()) {
entries.sort((a, b) => a.name.localeCompare(b.name));
}
return modules;
}
function renderSection(moduleName, entries) {
const lines = [];
lines.push(`# ${moduleName}`);
lines.push('');
lines.push(
'This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.',
);
lines.push('');
for (const entry of entries) {
lines.push(`## \`${moduleName}.${entry.name}\``);
lines.push('');
lines.push(`*Source: \`src/store/${entry.file}\`*`);
lines.push('');
if (entry.description) {
lines.push(entry.description);
lines.push('');
}
if (entry.params && entry.params.length) {
lines.push('**Parameters**');
lines.push('');
lines.push('| Name | Type | Description |');
lines.push('| --- | --- | --- |');
for (const p of entry.params) {
lines.push(`| \`${p.name}\` | \`${p.type}\` | ${p.description} |`);
}
lines.push('');
}
if (entry.returns) {
lines.push('**Returns**');
lines.push('');
lines.push(`\`${entry.returns.type}\`${entry.returns.description ? ` — ${entry.returns.description}` : ''}`);
lines.push('');
}
if (entry.throws) {
lines.push('**Throws**');
lines.push('');
lines.push(`\`${entry.throws.type}\`${entry.throws.description ? ` — ${entry.throws.description}` : ''}`);
lines.push('');
}
if (entry.example) {
lines.push('**Example**');
lines.push('');
lines.push('```js');
lines.push(entry.example);
lines.push('```');
lines.push('');
}
}
return lines.join('\n');
}
function formatParamType(param, allParams) {
const cleanName = param.name.replace(/^\[|\]$/g, '').split('=')[0].trim();
const prefix = cleanName + '.';
const childParams = allParams.filter((p) => {
const pClean = p.name.replace(/^\[|\]$/g, '').split('=')[0].trim();
return pClean.startsWith(prefix) && !pClean.slice(prefix.length).includes('.');
});
if (childParams.length > 0) {
const props = childParams.map((cp) => {
const isOpt = cp.name.startsWith('[');
const propName = cp.name.replace(/^\[|\]$/g, '').split('=')[0].trim().slice(prefix.length);
const propType = formatParamType(cp, allParams);
return `${propName}${isOpt ? '?' : ''}: ${propType}`;
});
return `{${props.join(', ')}}`;
}
return param.type || 'any';
}
function formatMethodSignature(entry) {
const topLevelParams = (entry.params || []).filter((p) => !p.name.includes('.'));
const paramsStr = topLevelParams
.map((p) => {
const isOptional = p.name.startsWith('[');
const cleanName = p.name.replace(/^\[|\]$/g, '').split('=')[0].trim();
const typeStr = formatParamType(p, entry.params || []);
return `${cleanName}${isOptional ? '?' : ''}: ${typeStr}`;
})
.join(', ');
const returnType = entry.returns?.type ? `: ${entry.returns.type}` : ': void';
return `(${paramsStr})${returnType}`;
}
function render() {
const modules = buildModules();
const ordered = MODULE_ORDER.filter((m) => modules.has(m));
const extra = [...modules.keys()].filter((m) => !MODULE_ORDER.includes(m)).sort();
const moduleNames = [...ordered, ...extra];
// Write one file per section: API.<section>.md
const written = [];
for (const moduleName of moduleNames) {
const sectionPath = join(__dirname, `API.${moduleName}.md`);
const markdown = renderSection(moduleName, modules.get(moduleName));
writeFileSync(sectionPath, markdown, 'utf8');
written.push({ moduleName, sectionPath, bytes: markdown.length });
}
const SCHEMA_TABLES = [
['account', 'id PK, company_id->company, type:s(100), name:s(255)'],
['article', 'id PK, sku?:s(100) UQ, unit_id->unit, unitamount:f, weight:f, track_stock:b=1, brand_id?->brand, articlelist_id?->articlelist, optionslist_id?->optionslist, is_parent:b=0, parentarticle_id?->article, parentattributename_id?->attributename, parentsort:i=0, created_at:dt, updated_at:dt, UQ(parentarticle_id, parentsort)'],
['articleattributevalue', 'id PK, article_id->article, attributevalue_id->attributevalue, sort:i=0, UQ(article_id, attributevalue_id)'],
['articleean', 'id PK, article_id->article, ean:s(50) UQ'],
['articlehistory', 'id PK, article_id->article, companyuser_id?->companyuser, date:dt, text:txt, meta?:txt'],
['articlelist', 'id PK'],
['articlelistarticle', 'id PK, articlelist_id->articlelist, article_id->article, count:i=1, sort:i=0, UQ(articlelist_id, article_id), UQ(articlelist_id, sort)'],
['articlepic', 'id PK, article_id->article, pic_id->pic, sort:i=0, UQ(article_id, pic_id), UQ(article_id, sort)'],
['articleproperty', 'id PK, article_id->article, property_id->propertyname, value:txt, sort:i=0'],
['articlesupplier', 'id PK, article_id->article, entity_id->entity, net?:f, tax_id?->tax, sku?:s(100), stock?:f, pu:f=1, sort:i=0, UQ(article_id, entity_id), UQ(article_id, sort)'],
['articletext', 'id PK, article_id->article, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(article_id, language_id)'],
['articletombstone', 'id PK, company_id->company, article_id?:i, sku?:s(100), name?:s(255), taxrate?:d(18,4), net?:f, deleted_at:dt'],
['attributename', 'id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)'],
['attributevalue', 'id PK, attribute_id->attributename, value:s(255), sort:i=0, UQ(attribute_id, value)'],
['attributevaluepic', 'id PK, attributevalue_id->attributevalue, pic_id->pic, sort:i=0, UQ(attributevalue_id, pic_id), UQ(attributevalue_id, sort)'],
['brand', 'id PK, company_id->company, name:s(255), gsprpinfo?:txt, UQ(company_id, name)'],
['brandpic', 'id PK, brand_id->brand, pic_id->pic, sort:i=0, UQ(brand_id, pic_id), UQ(brand_id, sort)'],
['category', 'id PK, parent_id?->category, sort:i=0'],
['categoryarticle', 'id PK, category_id->category, article_id->article, UQ(category_id, article_id)'],
['categoryattributevalue', 'id PK, category_id->category, attributevalue_id->attributevalue, sort:i=0, UQ(category_id, attributevalue_id)'],
['categorypic', 'id PK, category_id->category, pic_id->pic, sort:i=0, UQ(category_id, pic_id), UQ(category_id, sort)'],
['categoryproperty', 'id PK, category_id->category, property_id->propertyname, value:txt, sort:i=0'],
['categorytext', 'id PK, category_id->category, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(category_id, language_id)'],
['company', 'id PK, name:s(255) UQ, root_category_id?->category, default_language_id?->language, taxrate?->tax, reducedtaxrate?->tax, taxfreerate?->tax, poscustomergroup_id->customergroup'],
['companyhistory', 'id PK, company_id->company, companyuser_id?->companyuser, date:dt, text:txt'],
['companysetting', 'id PK, company_id->company, key:s(255), value:txt, frontend:b=0, writeonly:b=0, UQ(company_id, key)'],
['companyuser', 'id PK, google_auth_id->google_auth, company_id->company, UQ(google_auth_id, company_id)'],
['customergroup', 'id PK, name:s(255), sort:i=0, company_id?->company'],
['articlewarehousesummary', 'id PK, article_id->article, warehouse_id->warehouse, stock:f=0, UQ(article_id, warehouse_id)'],
['deliverysummary', 'id PK, company_id->company, manifest_id->manifest UQ, delivery_number:s(100), external_number?:s(100), delivery_date:dt, created_at:dt, supplier_id?->entity, supplier_name?:s(255), positions:i=0, gross_total:f=0, net_total:f=0, total_quantity:f=0, stock_positions:i=0, booked_positions:i=0, unbooked_positions:i=0, stock_quantity:f=0, booked_quantity:f=0, unbooked_quantity:f=0, is_booked:b=0'],
['entity', 'id PK, customergroup_id->customergroup, first_name?:s(255), name:s(255), number?:s(100), created_at:dt, updated_at:dt, UQ(company_id, number)'],
['entityaddress', 'id PK, entity_id->entity, first_name?:s(255), name?:s(255), street?:s(255), streetnumber?:s(50), zip?:s(50), city?:s(255), county?:s(255), addresstype:s(50), sort:i=0'],
['entitycontact', 'id PK, entity_id->entity, contacttype:s(50), contact:s(255), sort:i=0'],
['entityhistory', 'id PK, entity_id->entity, companyuser_id?->companyuser, date:dt, text:txt'],
['file', 'id PK, hash:s(64), mimetype:s(255), name:s(255), size:bi, created:dt, modified:dt, content:bin, thumbavif?:bin, thumbx?:i, thumby?:i, meta?:txt'],
['google_auth', 'id PK, email:s(320) UQ, picture?:s(2048), picturebinary?:bin, sub?:s(255) UQ, created_at:dt, selected_company?->company'],
['labeltemplate', 'id PK, company_id->company, name:s(255), type:s(50), width:f, height:f, data:txt, sort:i=0'],
['language', 'id PK, iso639:s(10) UQ'],
['ledger', 'id PK, manifestarticle_id->manifestarticle, warehouse_id?->warehouse, transaction_type:s(100), amount:f, transaction_text:txt, hash_version:i, prev_hash?:s(128) [append-only]'],
['manifest', 'id PK, company_id->company, number:s(100), type:s(100), entity_id?->entity, external_number?:s(100), date:dt, created_at:dt'],
['manifestarticle', 'id PK, manifest_id->manifest, sku?:s(100), name?:s(255), pu:f=1, amount:f, net:f, comment?:txt, rebate:f=0, taxrate:f=0'],
['manifestattachment', 'id PK, manifest_id->manifest, file_id->file, sort:i=0, UQ(manifest_id, sort)'],
['mcp_auth_code', 'code:s(128) PK, client_id:s(100), google_auth_id->google_auth, redirect_uri:s(2048), code_challenge:s(255), code_challenge_method:s(32), scope?:s(255), expires_at:dt, used:b=0, created_at:dt'],
['mcp_client', 'id PK, client_id:s(100) UQ, client_secret?:s(255), client_name:s(255), redirect_uris:txt, grant_types?:s(255), response_types?:s(255), scope?:s(255), created_at:dt'],
['mcp_token', 'id PK, refresh_token:s(128) UQ, client_id:s(100), google_auth_id->google_auth, scope?:s(255), expires_at:dt, revoked:b=0, created_at:dt'],
['meta', 'key:s(255) PK, value:txt'],
['modelconversation', 'id PK, companyuser_id->companyuser, provider:s(100), model:s(255), created_at:dt, updated_at:dt, state:s(20)=\'idle\', started_by?:i'],
['modelmessage', 'id PK, conversation_id->modelconversation, created_at:dt, role:s(20), message:txt'],
['modelvfs', 'id PK, company_id->company, name:s(255), mime:s(100), content:bin, meta?:txt, created_at:dt, updated_at:dt, UQ(company_id, name)'],
['numbers', 'id PK, company_id->company, type:s(100), prefix:s(50)=\'\', padding:i=0, next:i=1, UQ(company_id, type)'],
['optionslist', 'id PK'],
['optionslistarticle', 'id PK, optionslist_id->optionslist, article_id->article, count:i=1, sort:i=0, mandated:b=0, UQ(optionslist_id, article_id), UQ(optionslist_id, sort)'],
['ordersummary', 'id PK, company_id->company, manifest_id->manifest UQ, order_number:s(100), order_date:dt, created_at:dt, entity_id?->entity, customer_name?:s(255), positions:i=0, gross_total:f=0, paid_total:f=0, ordered_quantity:f=0, shipped_quantity:f=0, is_paid:b=0, is_shipped:b=0'],
['package', 'id PK, shipping_id->shipping, entityaddress_id->entityaddress, number:s(100), type:s(100), state:s(100), details?:txt'],
['payment', 'id PK, account_id->account, manifest_id->manifest, type:s(100), amount:f'],
['pic', 'id PK, w:i, h:i, size:bi, hash:s(64) UQ, thumbw:i, thumbh:i, thumbhash:s(64), source:txt, fullbinary:bin, thumbbinary:bin'],
['posterminal', 'id PK, company_id->company, name:s(255), token?:s(255) UQ, pairingpin?:s(50), root_category_id?->category, sync_root_articles:b=1, lastseen_at?:dt, updated_at:dt'],
['posterminalcategory', 'id PK, posterminal_id->posterminal, category_id->category, UQ(posterminal_id, category_id)'],
['price', 'id PK, tax_id->tax, article_id->article, minmultiplier:d(18,4)=1, customergroup_id->customergroup, net:f'],
['printer', 'id PK, company_id->company, address:s(255), type:s(50), model?:s(255), serial?:s(255), sort:i=0, desc?:txt'],
['propertyname', 'id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)'],
['quotesummary', 'id PK, company_id->company, manifest_id->manifest UQ, quote_number:s(100), quote_date:dt, created_at:dt, entity_id?->entity, customer_name?:s(255), positions:i=0, gross_total:f=0, total_quantity:f=0'],
['shipmentsummary', 'id PK, company_id->company, manifest_id->manifest UQ, shipment_number:s(100), shipment_date:dt, created_at:dt, order_id?:i, order_number?:s(100), entity_id?->entity, customer_name?:s(255), positions:i=0, total_quantity:f=0, package_count:i=0, carrier?:s(100), tracking_code?:s(255), state:s(50)=\'new\''],
['shipping', 'id PK, manifest_id->manifest, number:s(100), tracking_code?:s(255), carrier?:s(100), type:s(100), state:s(100)'],
['subagentschedule', 'id PK, company_id->company, desc?:txt, prompt:txt, skills?:txt, lastagentnote?:txt, max_itr:i=15, cron:s(100), created_at:dt, lastrun_at?:dt'],
['tax', 'id PK, rate:d(18,4) UQ'],
['unit', 'id PK, company_id->company, si:s(50), basesi:s(50), basesifactor:f, UQ(company_id, si)'],
['unittext', 'id PK, unit_id->unit, language_id->language, symbol:s(50), name:s(255), UQ(unit_id, language_id)'],
['warehouse', 'id PK, company_id->company, name:s(255), sort:i=0, UQ(company_id, name)'],
];
// Write the index API.md linking to each section file.
const lines = [];
lines.push('# LibDashStore API');
lines.push('');
lines.push(
'This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.',
);
lines.push('');
lines.push(
'The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each method is an async function that takes a `pool` (mssql connection pool) at construction time and returns a callable function. Methods are grouped by the namespace they are exposed under.',
);
lines.push('');
lines.push('## Database Schema');
lines.push('');
lines.push(
'> **Convention**: `PK` primary key · `?` nullable · `->tbl` foreign key · `UQ` unique · `s(N)` string · `txt` text · `i` int · `bi` bigint · `f` float · `d` decimal · `b` bit · `dt` datetime · `bin` binary · `=val` default',
);
lines.push('');
for (const [table, cols] of SCHEMA_TABLES) {
lines.push(cols ? `- **${table}**: \`${cols}\`` : `- **${table}**`);
}
lines.push('');
lines.push('## Table of Contents');
lines.push('');
for (const { moduleName } of written) {
lines.push(`- [${moduleName}](API.${moduleName}.md)`);
const entries = modules.get(moduleName) || [];
for (const entry of entries) {
const sig = formatMethodSignature(entry);
lines.push(` - \`${entry.name}${sig}\``);
}
}
lines.push('');
return { index: lines.join('\n'), written };
}
const { index, written } = render();
writeFileSync(outputPath, index, 'utf8');
console.log(`Wrote ${outputPath} (${index.length} bytes)`);
for (const { moduleName, sectionPath, bytes } of written) {
console.log(`Wrote ${sectionPath} (${bytes} bytes)`);
}