godcrm/backend/services/ModuleTableProvisioner.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

287 lines
11 KiB
JavaScript

/**
* ModuleTableProvisioner — Plan B (GERATRON 2026-07-20)
*
* The reusable primitive behind "at module creation, provision the module's tables
* in the target project, English-keyed by default (like the documents module), and
* return a table mapping."
*
* Before this, each module either (a) duplicated the createProject/createTable/
* createColumns trio (KanbanPackService, BusinessPackService, SystemTablesCreator, …)
* or (b) — like SC-SIM — hand-created tables with Cyrillic column keys and then paid
* for it with an 8-table rename migration (078 / ADR-177). A module that declares a
* blueprint here gets English keys from creation, so no rename migration ever exists.
*
* Blueprint shape (declarative, documents-style column templates):
* {
* key: 'my_module',
* tables: [{
* key: 'schemes', // logical key used in the returned mapping
* name: 'sim_schemes', // universal_tables.name — MUST be ASCII snake_case
* display_name: 'Схемы симуляций',// human label — any language, free-form
* icon: '🗺️',
* table_type: 'sim_schemes', // optional marker (ADR-164 style)
* columns: [
* { column_name: 'graph_json', display_name: 'Граф', type: 'json', config: {...} },
* ],
* }],
* }
*
* Returns a mapping keyed by each table's blueprint `key`:
* { schemes: { table_id, name, created, columns: [{ column_name, display_name, type }] } }
*
* A module may also declare a `seed` section (consumed by seedModuleRows below) to
* ship demo/default rows alongside its tables — the "module carries its own data"
* primitive that lets a fresh install open on a working project instead of an empty one:
* {
* key: 'my_module',
* tables: [...],
* seed: [{
* table: 'schemes', // logical table key — resolved through the mapping
* key: ['name'], // natural-key column(s) → idempotent re-seed
* rows: [
* { name: 'Demo scheme', graph_json: {...} },
* ],
* }],
* }
*/
import { dbRun, dbGet, withTransactionAsync, sqlNow } from '../database/connection.js';
import { apiLogger } from '../utils/logger.js';
import { generateBaseId } from '../utils/baseId.js';
// Machine keys must be ASCII snake_case. This is the rail that makes "English by
// default" enforced, not merely conventional — it is the SC-SIM Cyrillic-key bug
// class caught at the boundary. Display labels are deliberately NOT constrained.
const ASCII_KEY = /^[a-z][a-z0-9_]*$/;
function assertAsciiKey(kind, value) {
if (!ASCII_KEY.test(String(value || ''))) {
throw new Error(
`Invalid ${kind} "${value}": module table ${kind}s must be ASCII snake_case ` +
`(a-z, 0-9, _). Use display_name for human-readable labels.`
);
}
}
/**
* Provision (idempotently) all tables declared by a module blueprint into a project.
* @param {number} projectId - target project the module is being created in
* @param {object} blueprint - { key, tables: [...] }
* @param {object} [opts]
* @param {number} [opts.userId] - creator (created_by)
* @returns {Promise<Record<string, {table_id:number,name:string,created:boolean,columns:Array}>>}
*/
export async function provisionModuleTables(projectId, blueprint, { userId = null } = {}) {
if (!projectId) throw new Error('provisionModuleTables: projectId is required');
if (!blueprint || !Array.isArray(blueprint.tables) || blueprint.tables.length === 0) {
throw new Error('provisionModuleTables: blueprint.tables must be a non-empty array');
}
// Validate the whole blueprint up front so a bad key never half-provisions a module.
for (const t of blueprint.tables) {
assertAsciiKey('name', t.name);
for (const col of (t.columns || [])) assertAsciiKey('column_name', col.column_name);
}
const mapping = {};
for (const table of blueprint.tables) {
const existing = await dbGet(
`SELECT id FROM universal_tables WHERE project_id = ? AND name = ?`,
[projectId, table.name]
);
if (existing) {
mapping[table.key] = {
table_id: existing.id,
name: table.name,
created: false,
columns: (table.columns || []).map(pickColMeta),
};
apiLogger.debug(
{ projectId, table: table.name, tableId: existing.id, module: blueprint.key },
'[ModuleTableProvisioner] table already exists — reused'
);
continue;
}
const res = await dbRun(
`INSERT INTO universal_tables (project_id, name, display_name, table_type, icon, base_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
projectId,
table.name,
table.display_name || table.name,
table.table_type || null,
table.icon || null,
generateBaseId(),
userId,
]
);
const tableId = res.lastInsertRowid ?? res.lastID;
await createColumns(tableId, table.columns || []);
mapping[table.key] = {
table_id: tableId,
name: table.name,
created: true,
columns: (table.columns || []).map(pickColMeta),
};
apiLogger.info(
{ projectId, table: table.name, tableId, columns: (table.columns || []).length, module: blueprint.key },
'[ModuleTableProvisioner] table provisioned'
);
}
return mapping;
}
function pickColMeta(col) {
return { column_name: col.column_name, display_name: col.display_name, type: col.type };
}
async function createColumns(tableId, columns) {
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
await dbRun(
`INSERT INTO table_columns (table_id, column_name, display_name, type, order_index, is_visible, config)
VALUES (?, ?, ?, ?, ?, 1, ?)`,
[
tableId,
col.column_name,
col.display_name || col.column_name,
col.type || 'text',
col.order_index ?? (i + 1),
col.config ? JSON.stringify(col.config) : null,
]
);
}
}
/**
* Seed (idempotently) a module's declarative demo/default rows into the tables that
* provisionModuleTables just created — the companion that makes a module carry its own
* data. Rows land in the universal table_rows layer (table_id, base_id, JSON data), the
* same shape every pack service and the sim/ai-agents modules use — no bespoke storage.
*
* Idempotency is by NATURAL KEY, not row id: each seed group declares `key` (one or more
* column names) and a row is inserted only if no existing row in that table already
* carries the same key values. Re-running the seed — or installing over a project that
* already has the demo data — inserts nothing new. All reads + writes run inside a single
* transaction so a concurrent install cannot double-insert.
*
* @param {Record<string,{table_id:number}>} mapping - a {key -> {table_id}} mapping (from
* provisionModuleTables, or an equivalent adapter — see SIM install)
* @param {object} blueprint - the same blueprint; its optional `seed` array is consumed here
* @param {object} [opts]
* @param {number} [opts.userId] - creator (created_by) for seeded rows
* @param {object} [opts.trx] - an OPEN transaction handle (exposing `all`/`run`) to seed
* within. Pass this to compose provision+seed atomically in ONE transaction (e.g. a
* module install trigger: `withTransactionAsync(trx => { provision(trx); seedModuleRows(map, bp, { trx }); })`)
* so a fresh install is all-or-nothing. Omit it and seedModuleRows opens its own
* transaction — never nest a `withTransactionAsync` inside another (no nested BEGIN).
* @returns {Promise<Record<string,{seeded:number,reused:number}>>} per-table counts
*/
export async function seedModuleRows(mapping, blueprint, { userId = null, trx = null } = {}) {
if (!mapping || typeof mapping !== 'object') {
throw new Error('seedModuleRows: mapping (from provisionModuleTables) is required');
}
const seedGroups = blueprint?.seed;
if (seedGroups == null) return {}; // seed is optional — a module without demo data is fine
if (!Array.isArray(seedGroups)) {
throw new Error('seedModuleRows: blueprint.seed must be an array of { table, key, rows }');
}
// Validate the whole seed up front so a bad group never half-seeds a module.
for (const group of seedGroups) {
if (!group || !group.table) {
throw new Error('seedModuleRows: every seed group needs a `table` (blueprint table key)');
}
if (!mapping[group.table] || !mapping[group.table].table_id) {
throw new Error(
`seedModuleRows: seed group targets table "${group.table}" which is not in the ` +
`provisioned mapping — provisionModuleTables must run for it first`
);
}
if (!Array.isArray(group.key) || group.key.length === 0) {
throw new Error(
`seedModuleRows: seed group for "${group.table}" needs a non-empty \`key\` array ` +
`(natural-key column name(s)) so re-seeding stays idempotent`
);
}
for (const row of (group.rows || [])) {
for (const k of group.key) {
if (!(k in row)) {
throw new Error(
`seedModuleRows: a seed row for "${group.table}" is missing natural-key column ` +
`"${k}" — every seeded row must carry all of its key columns`
);
}
}
}
}
const counts = {};
// The seed body, parameterized on a transaction handle so it can run EITHER inside a
// caller-supplied transaction (atomic provision+seed) or in its own — same code path.
const seedWithin = async (t) => {
for (const group of seedGroups) {
const tableId = mapping[group.table].table_id;
const rows = group.rows || [];
// Load existing rows once and index them by natural-key signature (JS-side compare
// → dialect-agnostic, no json_extract). Demo seed sets are small, so a full scan is fine.
const existing = await t.all(
`SELECT data FROM table_rows WHERE table_id = ?`,
[tableId]
);
const seen = new Set(existing.map((r) => keySignature(parseData(r.data), group.key)));
let seeded = 0;
let reused = 0;
for (const row of rows) {
const sig = keySignature(row, group.key);
if (seen.has(sig)) {
reused++;
continue;
}
await t.run(
`INSERT INTO table_rows (table_id, base_id, data, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})`,
[tableId, generateBaseId(), JSON.stringify(row), userId]
);
seen.add(sig); // dedup duplicates within the same seed batch too
seeded++;
}
counts[group.table] = { seeded, reused };
apiLogger.info(
{ table: group.table, tableId, seeded, reused, module: blueprint.key },
'[ModuleTableProvisioner] rows seeded'
);
}
};
// Compose inside the caller's transaction when given one (provision+seed atomic); else
// run in our own. Both paths execute the identical seedWithin body.
if (trx) {
await seedWithin(trx);
} else {
await withTransactionAsync(seedWithin);
}
return counts;
}
function parseData(data) {
if (data == null) return {};
return typeof data === 'string' ? JSON.parse(data || '{}') : data;
}
// A stable, order-independent signature of a row's natural-key values. JSON.stringify of
// an array preserves the declared key order and cleanly distinguishes null/number/string.
function keySignature(row, keyCols) {
return JSON.stringify(keyCols.map((k) => row?.[k] ?? null));
}