Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
258 lines
9.5 KiB
JavaScript
258 lines
9.5 KiB
JavaScript
// Plan B (GERATRON 2026-07-20) — module tables are provisioned at module-creation
|
|
// time, English-keyed by default (documents-module style), returning a table mapping.
|
|
// This is the reusable primitive that makes the SC-SIM 8-table rename migration (078)
|
|
// a non-event: a module never hand-creates Cyrillic-keyed tables again.
|
|
//
|
|
// Unit test — mocks the DB (matches agent-table-resolver.test.js convention).
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
const mockDbRun = vi.fn();
|
|
const mockDbGet = vi.fn();
|
|
const mockWithTransactionAsync = vi.fn();
|
|
|
|
vi.mock('../../database/connection.js', () => ({
|
|
dbRun: (...a) => mockDbRun(...a),
|
|
dbGet: (...a) => mockDbGet(...a),
|
|
sqlNow: () => 'NOW()',
|
|
withTransactionAsync: (...a) => mockWithTransactionAsync(...a),
|
|
}));
|
|
|
|
vi.mock('../../utils/logger.js', () => ({
|
|
apiLogger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
}));
|
|
|
|
const { provisionModuleTables, seedModuleRows } = await import('../ModuleTableProvisioner.js');
|
|
|
|
const BLUEPRINT = {
|
|
key: 'sample',
|
|
tables: [
|
|
{
|
|
key: 'schemes',
|
|
name: 'sim_schemes',
|
|
display_name: 'Схемы симуляций',
|
|
icon: '🗺️',
|
|
table_type: 'sim_schemes',
|
|
columns: [
|
|
{ column_name: 'name', display_name: 'Название', type: 'text' },
|
|
{ column_name: 'graph_json', display_name: 'Граф', type: 'json' },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockDbRun.mockReset();
|
|
mockDbGet.mockReset();
|
|
mockWithTransactionAsync.mockReset();
|
|
});
|
|
|
|
// An in-memory table_rows backing store so seedModuleRows' idempotency (natural-key
|
|
// dedup across calls) can be exercised for real, not just asserted on mock call counts.
|
|
function installFakeTransaction() {
|
|
const store = []; // { table_id, base_id, data (JSON string), created_by }
|
|
mockWithTransactionAsync.mockImplementation(async (cb) => {
|
|
let seq = 1000;
|
|
const trx = {
|
|
all: async (sql, params) => {
|
|
if (/SELECT data FROM table_rows WHERE table_id = \?/i.test(sql)) {
|
|
const [tableId] = params;
|
|
return store.filter((r) => r.table_id === tableId).map((r) => ({ data: r.data }));
|
|
}
|
|
return [];
|
|
},
|
|
run: async (sql, params) => {
|
|
if (/^\s*INSERT INTO table_rows/i.test(sql)) {
|
|
const [table_id, base_id, data, created_by] = params;
|
|
store.push({ table_id, base_id, data, created_by });
|
|
return { lastInsertRowid: ++seq, changes: 1 };
|
|
}
|
|
return { changes: 0 };
|
|
},
|
|
};
|
|
return cb(trx);
|
|
});
|
|
return store;
|
|
}
|
|
|
|
describe('provisionModuleTables', () => {
|
|
it('creates a new table with English-keyed columns and returns the mapping', async () => {
|
|
mockDbGet.mockResolvedValue(undefined); // nothing exists yet
|
|
let seq = 100;
|
|
mockDbRun.mockImplementation(() => Promise.resolve({ lastInsertRowid: ++seq }));
|
|
|
|
const mapping = await provisionModuleTables(7814, BLUEPRINT, { userId: 1 });
|
|
|
|
// table row created
|
|
expect(mapping.schemes).toBeDefined();
|
|
expect(mapping.schemes.table_id).toBe(101);
|
|
expect(mapping.schemes.name).toBe('sim_schemes');
|
|
expect(mapping.schemes.created).toBe(true);
|
|
|
|
// English-keyed columns returned in the mapping
|
|
expect(mapping.schemes.columns.map(c => c.column_name)).toEqual(['name', 'graph_json']);
|
|
|
|
// one INSERT for the table + one per column
|
|
const inserts = mockDbRun.mock.calls.map(c => String(c[0]));
|
|
expect(inserts.filter(s => /INSERT INTO universal_tables/i.test(s))).toHaveLength(1);
|
|
expect(inserts.filter(s => /INSERT INTO table_columns/i.test(s))).toHaveLength(2);
|
|
});
|
|
|
|
it('is idempotent — reuses an existing table by (project_id, name), no re-insert', async () => {
|
|
mockDbGet.mockResolvedValue({ id: 555 }); // table already exists
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 999 });
|
|
|
|
const mapping = await provisionModuleTables(7814, BLUEPRINT, { userId: 1 });
|
|
|
|
expect(mapping.schemes.table_id).toBe(555);
|
|
expect(mapping.schemes.created).toBe(false);
|
|
// no table or column inserts happened
|
|
expect(mockDbRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a non-ASCII (Cyrillic) column_name — enforces English-by-default', async () => {
|
|
mockDbGet.mockResolvedValue(undefined);
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 1 });
|
|
|
|
const bad = {
|
|
key: 'bad',
|
|
tables: [{
|
|
key: 't', name: 'sim_bad', display_name: 'Bad',
|
|
columns: [{ column_name: 'персонал', display_name: 'Персонал', type: 'number' }],
|
|
}],
|
|
};
|
|
|
|
await expect(provisionModuleTables(7814, bad, { userId: 1 }))
|
|
.rejects.toThrow(/column_name/i);
|
|
});
|
|
});
|
|
|
|
const SEED_BLUEPRINT = {
|
|
key: 'my_module',
|
|
tables: BLUEPRINT.tables,
|
|
seed: [
|
|
{
|
|
table: 'schemes',
|
|
key: ['name'],
|
|
rows: [
|
|
{ name: 'Demo scheme A', graph_json: { nodes: [] } },
|
|
{ name: 'Demo scheme B', graph_json: { nodes: [1] } },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
// mapping as provisionModuleTables would return it
|
|
const MAPPING = { schemes: { table_id: 101, name: 'sim_schemes', created: true, columns: [] } };
|
|
|
|
describe('seedModuleRows', () => {
|
|
it('inserts declared demo rows into the universal table_rows layer and returns counts', async () => {
|
|
const store = installFakeTransaction();
|
|
|
|
const counts = await seedModuleRows(MAPPING, SEED_BLUEPRINT, { userId: 7 });
|
|
|
|
expect(counts.schemes).toEqual({ seeded: 2, reused: 0 });
|
|
expect(store).toHaveLength(2);
|
|
// rows landed in the (table_id, base_id, JSON data, created_by) shape
|
|
expect(store[0].table_id).toBe(101);
|
|
expect(store[0].base_id).toEqual(expect.any(String));
|
|
expect(store[0].created_by).toBe(7);
|
|
expect(JSON.parse(store[0].data)).toEqual({ name: 'Demo scheme A', graph_json: { nodes: [] } });
|
|
});
|
|
|
|
it('is idempotent by natural key — seeding twice inserts nothing the second time', async () => {
|
|
const store = installFakeTransaction();
|
|
|
|
const first = await seedModuleRows(MAPPING, SEED_BLUEPRINT, { userId: 7 });
|
|
expect(first.schemes).toEqual({ seeded: 2, reused: 0 });
|
|
expect(store).toHaveLength(2);
|
|
|
|
const second = await seedModuleRows(MAPPING, SEED_BLUEPRINT, { userId: 7 });
|
|
expect(second.schemes).toEqual({ seeded: 0, reused: 2 });
|
|
expect(store).toHaveLength(2); // no duplicates
|
|
});
|
|
|
|
it('dedups duplicate rows within a single seed batch', async () => {
|
|
const store = installFakeTransaction();
|
|
const dupBlueprint = {
|
|
key: 'my_module',
|
|
seed: [{
|
|
table: 'schemes',
|
|
key: ['name'],
|
|
rows: [
|
|
{ name: 'Only once', graph_json: {} },
|
|
{ name: 'Only once', graph_json: { changed: true } }, // same natural key
|
|
],
|
|
}],
|
|
};
|
|
|
|
const counts = await seedModuleRows(MAPPING, dupBlueprint, { userId: 1 });
|
|
|
|
expect(counts.schemes).toEqual({ seeded: 1, reused: 1 });
|
|
expect(store).toHaveLength(1);
|
|
});
|
|
|
|
it('is a no-op when the blueprint declares no seed section', async () => {
|
|
installFakeTransaction();
|
|
const counts = await seedModuleRows(MAPPING, BLUEPRINT, { userId: 1 });
|
|
expect(counts).toEqual({});
|
|
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a seed group targeting a table absent from the mapping', async () => {
|
|
installFakeTransaction();
|
|
const bad = { key: 'x', seed: [{ table: 'ghost', key: ['name'], rows: [{ name: 'a' }] }] };
|
|
await expect(seedModuleRows(MAPPING, bad, { userId: 1 }))
|
|
.rejects.toThrow(/not in the provisioned mapping/i);
|
|
});
|
|
|
|
it('rejects a seed group with no natural key (idempotency would be undefined)', async () => {
|
|
installFakeTransaction();
|
|
const bad = { key: 'x', seed: [{ table: 'schemes', rows: [{ name: 'a' }] }] };
|
|
await expect(seedModuleRows(MAPPING, bad, { userId: 1 }))
|
|
.rejects.toThrow(/non-empty `key`/i);
|
|
});
|
|
|
|
it('rejects a seed row missing one of its natural-key columns', async () => {
|
|
installFakeTransaction();
|
|
const bad = {
|
|
key: 'x',
|
|
seed: [{ table: 'schemes', key: ['name'], rows: [{ graph_json: {} }] }],
|
|
};
|
|
await expect(seedModuleRows(MAPPING, bad, { userId: 1 }))
|
|
.rejects.toThrow(/missing natural-key column/i);
|
|
});
|
|
|
|
it('composes inside a caller-supplied trx (atomic provision+seed) without opening its own', async () => {
|
|
// A bare in-memory trx handle — the caller (e.g. an install trigger inside its own
|
|
// withTransactionAsync) hands this to seedModuleRows so provision+seed are one transaction.
|
|
const store = [];
|
|
let seq = 2000;
|
|
const trx = {
|
|
all: async (sql, params) =>
|
|
/SELECT data FROM table_rows WHERE table_id = \?/i.test(sql)
|
|
? store.filter((r) => r.table_id === params[0]).map((r) => ({ data: r.data }))
|
|
: [],
|
|
run: async (sql, params) => {
|
|
if (/^\s*INSERT INTO table_rows/i.test(sql)) {
|
|
const [table_id, base_id, data, created_by] = params;
|
|
store.push({ table_id, base_id, data, created_by });
|
|
return { lastInsertRowid: ++seq, changes: 1 };
|
|
}
|
|
return { changes: 0 };
|
|
},
|
|
};
|
|
|
|
const counts = await seedModuleRows(MAPPING, SEED_BLUEPRINT, { userId: 9, trx });
|
|
|
|
expect(counts.schemes).toEqual({ seeded: 2, reused: 0 });
|
|
expect(store).toHaveLength(2);
|
|
// the whole point: it used the caller's trx, it did NOT open its own transaction
|
|
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
|
|
|
|
// idempotency holds across a second call sharing the same trx/store
|
|
const second = await seedModuleRows(MAPPING, SEED_BLUEPRINT, { userId: 9, trx });
|
|
expect(second.schemes).toEqual({ seeded: 0, reused: 2 });
|
|
expect(store).toHaveLength(2);
|
|
});
|
|
});
|