This commit is contained in:
seb
2026-10-03 23:13:40 +02:00
parent 6520665dcf
commit 3b74c12f4e
36 changed files with 3669 additions and 2183 deletions

View File

@@ -41,7 +41,7 @@ export default function createUnderParent(provider) {
* const article = await store.article.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Cola' });
* // => { id, parentId, name, sku, track_stock }
*/
return async ({ googleAuthId, parentId, name, track_stock, trackStock }, tx) => {
return async ({ googleAuthId, parentId, name, track_stock, trackStock, sku: inputSku }, tx) => {
const rawTrack = track_stock ?? trackStock;
const finalTrackStock = rawTrack == null ? 1 : (rawTrack ? 1 : 0);
@@ -53,7 +53,7 @@ export default function createUnderParent(provider) {
);
const unitId = await ensureUnit(runner, languageId, companyId);
const sku = await allocateSku(companyId, runner);
const sku = (typeof inputSku === 'string' && inputSku.trim()) ? inputSku.trim() : await allocateSku(companyId, runner);
const slug = await resolveSlug(runner, { companyId, languageId, name });
let article;

View File

@@ -0,0 +1,42 @@
import { CREATE as MSSQL_CREATE } from '../../providers/mssql/queries/modelconversation.js';
import { CREATE as SQLITE_CREATE } from '../../providers/sqlite3/queries/modelconversation.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import shape from './shape.js';
export default function create(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_CREATE : MSSQL_CREATE;
/**
* Creates a new (idle) model conversation for the user in the selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - Conversation input.
* @param {string} input.provider - LLM provider name.
* @param {string} input.model - Model identifier.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>}
* @throws {Error} 'Provider required' or 'Model required'.
*
* @example
* const c = await store.modelconversation.create(42, { provider: 'openrouter', model: 'openai/gpt-5' });
*/
return async (googleAuthId, input = {}, tx) => {
const providerName =
typeof input.provider === 'string' ? input.provider.trim() : '';
if (!providerName) throw new Error('Provider required');
const model = typeof input.model === 'string' ? input.model.trim() : '';
if (!model) throw new Error('Model required');
const runner = makeRunner(provider, tx);
const { companyuserId } = await access(googleAuthId, runner);
const result = await runner.query(sql, {
companyuserId,
provider: providerName,
model,
});
return shape(result.rows[0]);
};
}

View File

@@ -0,0 +1,34 @@
import { DELETE_BY_COMPANYUSER_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/modelconversation.js';
import { DELETE_BY_COMPANYUSER_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/modelconversation.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql =
provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes a model conversation by ID for the user in the selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} conversationId - The conversation id.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} 'Conversation not found'.
*/
return async (googleAuthId, conversationId, tx) => {
const id = Number(conversationId);
if (!Number.isInteger(id) || id <= 0) {
throw new Error('Conversation not found');
}
const runner = makeRunner(provider, tx);
const { companyuserId } = await access(googleAuthId, runner);
const result = await runner.query(sql, { id, companyuserId });
if (!result.rowsAffected) {
throw new Error('Conversation not found');
}
};
}

View File

@@ -0,0 +1,33 @@
import { GET_BY_COMPANYUSER_AND_ID as MSSQL_GET } from '../../providers/mssql/queries/modelconversation.js';
import { GET_BY_COMPANYUSER_AND_ID as SQLITE_GET } from '../../providers/sqlite3/queries/modelconversation.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import shape from './shape.js';
export default function get(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET;
/**
* Gets a single model conversation by ID for the user in the selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} conversationId - The conversation id.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>}
* @throws {Error} 'Conversation not found'.
*/
return async (googleAuthId, conversationId, tx) => {
const id = Number(conversationId);
if (!Number.isInteger(id) || id <= 0) {
throw new Error('Conversation not found');
}
const runner = makeRunner(provider, tx);
const { companyuserId } = await access(googleAuthId, runner);
const result = await runner.query(sql, { id, companyuserId });
if (!result.rows[0]) throw new Error('Conversation not found');
return shape(result.rows[0]);
};
}

View File

@@ -0,0 +1,27 @@
import { LIST_BY_COMPANYUSER as MSSQL_LIST } from '../../providers/mssql/queries/modelconversation.js';
import { LIST_BY_COMPANYUSER as SQLITE_LIST } from '../../providers/sqlite3/queries/modelconversation.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import shape from './shape.js';
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_LIST : MSSQL_LIST;
/**
* Lists the user's own model conversations in the selected company, newest activity first.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>>}
*
* @example
* const conversations = await store.modelconversation.list(42);
*/
return async (googleAuthId, tx) => {
const runner = makeRunner(provider, tx);
const { companyuserId } = await access(googleAuthId, runner);
const result = await runner.query(sql, { companyuserId });
return result.rows.map(shape);
};
}

View File

@@ -0,0 +1,37 @@
import { SET_STATE as MSSQL_SET_STATE } from '../../providers/mssql/queries/modelconversation.js';
import { SET_STATE as SQLITE_SET_STATE } from '../../providers/sqlite3/queries/modelconversation.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import shape from './shape.js';
export default function setState(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_SET_STATE : MSSQL_SET_STATE;
/**
* Sets the state of a model conversation ('idle' | 'busy').
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} conversationId - The conversation id.
* @param {string} state - The new state ('idle' | 'busy').
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>}
* @throws {Error} 'Invalid state' or 'Conversation not found'.
*/
return async (googleAuthId, conversationId, state, tx) => {
const id = Number(conversationId);
if (!Number.isInteger(id) || id <= 0) {
throw new Error('Conversation not found');
}
if (state !== 'idle' && state !== 'busy') {
throw new Error("Invalid state: must be 'idle' or 'busy'");
}
const runner = makeRunner(provider, tx);
const { companyuserId } = await access(googleAuthId, runner);
const result = await runner.query(sql, { id, companyuserId, state });
if (!result.rows[0]) throw new Error('Conversation not found');
return shape(result.rows[0]);
};
}

View File

@@ -0,0 +1,11 @@
export default function shape(row) {
return {
id: row.id,
companyuser_id: row.companyuser_id ?? row.companyuserId,
provider: row.provider,
model: row.model,
created_at: row.created_at ?? row.createdAt,
updated_at: row.updated_at ?? row.updatedAt,
state: row.state,
};
}

View File

@@ -0,0 +1,85 @@
import {
GET_BY_COMPANYUSER_AND_ID as MSSQL_GET_CONV,
TOUCH as MSSQL_TOUCH_UNCONDITIONAL,
TOUCH_IF_IDLE as MSSQL_TOUCH,
} from '../../providers/mssql/queries/modelconversation.js';
import { CREATE as MSSQL_CREATE } from '../../providers/mssql/queries/modelmessage.js';
import {
GET_BY_COMPANYUSER_AND_ID as SQLITE_GET_CONV,
TOUCH as SQLITE_TOUCH_UNCONDITIONAL,
TOUCH_IF_IDLE as SQLITE_TOUCH,
} from '../../providers/sqlite3/queries/modelconversation.js';
import { CREATE as SQLITE_CREATE } from '../../providers/sqlite3/queries/modelmessage.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import shape from './shape.js';
export default function add(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getConvSql = isSqlite ? SQLITE_GET_CONV : MSSQL_GET_CONV;
const touchSql = isSqlite ? SQLITE_TOUCH : MSSQL_TOUCH;
const touchUnconditionalSql = isSqlite ? SQLITE_TOUCH_UNCONDITIONAL : MSSQL_TOUCH_UNCONDITIONAL;
const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE;
/**
* Appends a message to one of the user's own conversations.
* `user` messages are refused unless the conversation state is `idle`;
* other roles ('assistant', 'system', 'tool', 'reasoning') touch `updated_at` unconditionally.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} conversationId - The conversation id.
* @param {object} input - Message input.
* @param {string} input.message - Message text.
* @param {'user'|'assistant'|'system'|'tool'|'reasoning'} [input.role='user'] - Message role.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, conversation_id: number, created_at: string, role: string, message: string}>}
* @throws {Error} 'Conversation not found', 'Conversation is not idle', 'Message required', 'Invalid role'.
*/
return async (googleAuthId, conversationId, input = {}, tx) => {
const id = Number(conversationId);
if (!Number.isInteger(id) || id <= 0) {
throw new Error('Conversation not found');
}
const role = input.role || 'user';
if (!['user', 'assistant', 'system', 'tool', 'reasoning'].includes(role)) {
throw new Error('Invalid role');
}
const message = input.message != null ? String(input.message) : '';
if (!message.trim()) {
throw new Error('Message required');
}
const run = async (runner) => {
const { companyuserId } = await access(googleAuthId, runner);
if (role === 'user') {
const touched = await runner.query(touchSql, { id, companyuserId });
if (!touched.rowsAffected) {
const conv = await runner.query(getConvSql, { id, companyuserId });
if (!conv.rows[0]) throw new Error('Conversation not found');
throw new Error('Conversation is not idle');
}
} else {
const touched = await runner.query(touchUnconditionalSql, { id, companyuserId });
if (!touched.rowsAffected) {
throw new Error('Conversation not found');
}
}
const result = await runner.query(createSql, {
conversationId: id,
role,
message,
});
return shape(result.rows[0]);
};
if (tx) return run(makeRunner(provider, tx));
if (typeof provider?.transaction === 'function') {
return provider.transaction(run);
}
return run(makeRunner(provider));
};
}

View File

@@ -0,0 +1,42 @@
import { GET_BY_COMPANYUSER_AND_ID as MSSQL_GET_CONV } from '../../providers/mssql/queries/modelconversation.js';
import { LIST_BY_CONVERSATION as MSSQL_LIST } from '../../providers/mssql/queries/modelmessage.js';
import { GET_BY_COMPANYUSER_AND_ID as SQLITE_GET_CONV } from '../../providers/sqlite3/queries/modelconversation.js';
import { LIST_BY_CONVERSATION as SQLITE_LIST } from '../../providers/sqlite3/queries/modelmessage.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import shape from './shape.js';
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getConvSql = isSqlite ? SQLITE_GET_CONV : MSSQL_GET_CONV;
const listSql = isSqlite ? SQLITE_LIST : MSSQL_LIST;
/**
* Lists messages of one of the user's own conversations, oldest first.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} conversationId - The conversation id.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, conversation_id: number, created_at: string, role: string, message: string}>>}
* @throws {Error} 'Conversation not found' if missing or owned by another user.
*
* @example
* const messages = await store.modelmessage.list(42, 7);
*/
return async (googleAuthId, conversationId, tx) => {
const id = Number(conversationId);
if (!Number.isInteger(id) || id <= 0) {
throw new Error('Conversation not found');
}
const runner = makeRunner(provider, tx);
const { companyuserId } = await access(googleAuthId, runner);
const conv = await runner.query(getConvSql, { id, companyuserId });
if (!conv.rows[0]) throw new Error('Conversation not found');
const result = await runner.query(listSql, { conversationId: id });
return result.rows.map(shape);
};
}

View File

@@ -0,0 +1,9 @@
export default function shape(row) {
return {
id: row.id,
conversation_id: row.conversation_id ?? row.conversationId,
created_at: row.created_at ?? row.createdAt,
role: row.role,
message: row.message,
};
}

View File

@@ -0,0 +1,37 @@
import { DELETE_BY_COMPANY_AND_NAME as MSSQL_DELETE } from '../../providers/mssql/queries/modelvfs.js';
import { DELETE_BY_COMPANY_AND_NAME as SQLITE_DELETE } from '../../providers/sqlite3/queries/modelvfs.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function deleteFile(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes a virtual file by name for the selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} name - File name.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{deleted: boolean}>}
*/
return async (googleAuthId, name, tx) => {
const filename = typeof name === 'string' ? name.trim() : '';
if (!filename) throw new Error('File name is required');
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const result = await runner.query(sql, { companyId, name: filename });
const rows = result.rows || [];
if (provider?.dialect === 'sqlite3') {
for (const row of rows) {
if (runner.deleteVfsContent) await runner.deleteVfsContent(row.id);
else if (provider.deleteVfsContent) await provider.deleteVfsContent(row.id);
}
}
return { deleted: rows.length > 0 };
};
}

View File

@@ -0,0 +1,25 @@
import { LIST_BY_COMPANY as MSSQL_LIST } from '../../providers/mssql/queries/modelvfs.js';
import { LIST_BY_COMPANY as SQLITE_LIST } from '../../providers/sqlite3/queries/modelvfs.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import shape from './shape.js';
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_LIST : MSSQL_LIST;
/**
* Lists virtual files for the selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<Array<{id: number, company_id: number, name: string, mime: string, meta: any, created_at: string, updated_at: string}>>}
*/
return async (googleAuthId, tx) => {
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const result = await runner.query(sql, { companyId });
return result.rows.map(shape);
};
}

View File

@@ -0,0 +1,49 @@
import { GET_BY_COMPANY_AND_NAME as MSSQL_GET } from '../../providers/mssql/queries/modelvfs.js';
import { GET_BY_COMPANY_AND_NAME as SQLITE_GET } from '../../providers/sqlite3/queries/modelvfs.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import shape from './shape.js';
export default function read(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET;
/**
* Reads a virtual file by name for the selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} name - File name.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{id: number, company_id: number, name: string, mime: string, content: Buffer, meta: any, created_at: string, updated_at: string}>}
* @throws {Error} 'File not found'
*/
return async (googleAuthId, name, tx) => {
const filename = typeof name === 'string' ? name.trim() : '';
if (!filename) throw new Error('File name is required');
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const result = await runner.query(sql, { companyId, name: filename });
const row = result.rows[0];
if (!row) {
throw new Error('File not found');
}
let content;
if (provider?.dialect === 'sqlite3') {
content = runner.readVfsContent
? await runner.readVfsContent(row.id)
: await provider.readVfsContent?.(row.id);
if (!content) content = Buffer.alloc(0);
} else {
content = row.content;
}
return {
...shape(row),
content,
size: content?.length || 0,
};
};
}

View File

@@ -0,0 +1,33 @@
import { DELETE_ALL_BY_COMPANY as MSSQL_RESET } from '../../providers/mssql/queries/modelvfs.js';
import { DELETE_ALL_BY_COMPANY as SQLITE_RESET } from '../../providers/sqlite3/queries/modelvfs.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function reset(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_RESET : MSSQL_RESET;
/**
* Deletes all virtual files for the selected company (reset VFS).
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{deletedCount: number}>}
*/
return async (googleAuthId, tx) => {
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const result = await runner.query(sql, { companyId });
const rows = result.rows || [];
if (provider?.dialect === 'sqlite3') {
for (const row of rows) {
if (runner.deleteVfsContent) await runner.deleteVfsContent(row.id);
else if (provider.deleteVfsContent) await provider.deleteVfsContent(row.id);
}
}
return { deletedCount: rows.length };
};
}

View File

@@ -0,0 +1,21 @@
export default function shape(row) {
if (!row) return null;
let parsedMeta = null;
if (row.meta) {
try {
parsedMeta = typeof row.meta === 'string' ? JSON.parse(row.meta) : row.meta;
} catch {
parsedMeta = row.meta;
}
}
return {
id: row.id,
company_id: row.company_id,
name: row.name,
mime: row.mime,
meta: parsedMeta,
created_at: row.created_at,
updated_at: row.updated_at,
...(row.content !== undefined ? { content: row.content } : {}),
};
}

View File

@@ -0,0 +1,61 @@
import { UPSERT as MSSQL_UPSERT } from '../../providers/mssql/queries/modelvfs.js';
import { UPSERT as SQLITE_UPSERT } from '../../providers/sqlite3/queries/modelvfs.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import shape from './shape.js';
export default function write(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_UPSERT : MSSQL_UPSERT;
/**
* Writes (inserts or overwrites) a virtual file for the selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input
* @param {string} input.name - File path/name (e.g. 'candidate.avif', 'report.json').
* @param {string} [input.mime='application/octet-stream'] - Mime type.
* @param {Buffer|string} input.content - File content buffer or string.
* @param {object|string} [input.meta] - Optional metadata object or JSON string.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The stored file record metadata.
*/
return async (googleAuthId, input = {}, tx) => {
const name = typeof input.name === 'string' ? input.name.trim() : '';
if (!name) throw new Error('File name is required');
const mime = typeof input.mime === 'string' && input.mime.trim() ? input.mime.trim() : 'application/octet-stream';
const contentBuffer = Buffer.isBuffer(input.content)
? input.content
: Buffer.from(input.content != null ? String(input.content) : '');
const metaStr = input.meta != null
? (typeof input.meta === 'object' ? JSON.stringify(input.meta) : String(input.meta))
: null;
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const params = {
companyId,
name,
mime,
meta: metaStr,
...(provider?.dialect !== 'sqlite3' ? { content: contentBuffer } : {}),
};
const result = await runner.query(sql, params);
const row = result.rows[0];
if (provider?.dialect === 'sqlite3' && row?.id) {
if (runner.writeVfsContent) {
await runner.writeVfsContent(row.id, contentBuffer);
} else if (provider.writeVfsContent) {
await provider.writeVfsContent(row.id, contentBuffer);
}
}
return {
...shape(row),
size: contentBuffer.length,
};
};
}

View File

@@ -71,5 +71,8 @@ function createCallableRunner(base) {
run.readPicThumb = base.readPicThumb?.bind(base);
run.deleteFile = base.deleteFile?.bind(base);
run.deletePic = base.deletePic?.bind(base);
run.writeVfsContent = base.writeVfsContent?.bind(base);
run.readVfsContent = base.readVfsContent?.bind(base);
run.deleteVfsContent = base.deleteVfsContent?.bind(base);
return run;
}