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

15
API.md
View File

@@ -281,6 +281,21 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `delete(googleAuthId: number, categoryId: number, propertyId: number, tx?: object): Promise<void>` - `delete(googleAuthId: number, categoryId: number, propertyId: number, tx?: object): Promise<void>`
- `listByCategoryId(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<{id: number, categoryId: number, propertyId: number, name: string, exclusive: boolean, value: string, sort: number}>>` - `listByCategoryId(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<{id: number, categoryId: number, propertyId: number, name: string, exclusive: boolean, value: string, sort: number}>>`
- `setByCategoryId(googleAuthId: number, categoryId: number, properties: Array<{propertyId: number, value: string, sort?: number}>, tx?: object): Promise<Array<{id: number, categoryId: number, propertyId: number, name: string, exclusive: boolean, value: string, sort: number}>>` - `setByCategoryId(googleAuthId: number, categoryId: number, properties: Array<{propertyId: number, value: string, sort?: number}>, tx?: object): Promise<Array<{id: number, categoryId: number, propertyId: number, name: string, exclusive: boolean, value: string, sort: number}>>`
- [modelconversation](API.modelconversation.md)
- `create(googleAuthId: number, input: {provider: string, model: string}, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>`
- `delete(googleAuthId: number, conversationId: number, tx?: object): Promise<void>`
- `get(googleAuthId: number, conversationId: number, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>>`
- `setState(googleAuthId: number, conversationId: number, state: string, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>`
- [modelmessage](API.modelmessage.md)
- `add(googleAuthId: number, conversationId: number, input: {message: string, role?: 'user'|'assistant'|'system'|'tool'|'reasoning'}, tx?: object): Promise<{id: number, conversation_id: number, created_at: string, role: string, message: string}>`
- `list(googleAuthId: number, conversationId: number, tx?: object): Promise<Array<{id: number, conversation_id: number, created_at: string, role: string, message: string}>>`
- [modelvfs](API.modelvfs.md)
- `delete(googleAuthId: number, name: string, tx?: object): Promise<{deleted: boolean}>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, company_id: number, name: string, mime: string, meta: any, created_at: string, updated_at: string}>>`
- `read(googleAuthId: number, name: string, tx?: object): Promise<{id: number, company_id: number, name: string, mime: string, content: Buffer, meta: any, created_at: string, updated_at: string}>`
- `reset(googleAuthId: number, tx?: object): Promise<{deletedCount: number}>`
- `write(googleAuthId: number, input: {name: string, mime?: string, content: Buffer|string, meta?: object|string}, tx?: object): Promise<object>`
- [optionslist](API.optionslist.md) - [optionslist](API.optionslist.md)
- `getOptionsByArticleSku(googleAuthId: number, sku: string, tx?: object): Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number, mandated: boolean}>>` - `getOptionsByArticleSku(googleAuthId: number, sku: string, tx?: object): Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number, mandated: boolean}>>`
- `setOptionsByArticleSku(googleAuthId: number, sku: string, options?: Array<{articleId?: number, sku?: string, count?: number, sort?: number, mandated?: boolean}>, tx?: object): Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number, mandated: boolean}>>` - `setOptionsByArticleSku(googleAuthId: number, sku: string, options?: Array<{articleId?: number, sku?: string, count?: number, sort?: number, mandated?: boolean}>, tx?: object): Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number, mandated: boolean}>>`

123
API.modelconversation.md Normal file
View File

@@ -0,0 +1,123 @@
# modelconversation
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `modelconversation.create`
*Source: `src/store/modelconversation/create.js`*
Creates a new (idle) model conversation for the user in the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | Conversation input. |
| `input.provider` | `string` | LLM provider name. |
| `input.model` | `string` | Model identifier. |
| `[tx]` | `object` | 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**
```js
const c = await store.modelconversation.create(42, { provider: 'openrouter', model: 'openai/gpt-5' });
```
## `modelconversation.delete`
*Source: `src/store/modelconversation/delete.js`*
Deletes a model conversation by ID for the user in the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `conversationId` | `number` | The conversation id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Throws**
`Error` — 'Conversation not found'.
## `modelconversation.get`
*Source: `src/store/modelconversation/get.js`*
Gets a single model conversation by ID for the user in the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `conversationId` | `number` | The conversation id. |
| `[tx]` | `object` | 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'.
## `modelconversation.list`
*Source: `src/store/modelconversation/list.js`*
Lists the user's own model conversations in the selected company, newest activity first.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | 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**
```js
const conversations = await store.modelconversation.list(42);
```
## `modelconversation.setState`
*Source: `src/store/modelconversation/setState.js`*
Sets the state of a model conversation ('idle' | 'busy').
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `conversationId` | `number` | The conversation id. |
| `state` | `string` | The new state ('idle' | 'busy'). |
| `[tx]` | `object` | 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'.

56
API.modelmessage.md Normal file
View File

@@ -0,0 +1,56 @@
# modelmessage
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `modelmessage.add`
*Source: `src/store/modelmessage/add.js`*
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.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `conversationId` | `number` | The conversation id. |
| `input` | `object` | Message input. |
| `input.message` | `string` | Message text. |
| `[input.role='user']` | `'user'|'assistant'|'system'|'tool'|'reasoning'` | Message role. |
| `[tx]` | `object` | 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'.
## `modelmessage.list`
*Source: `src/store/modelmessage/list.js`*
Lists messages of one of the user's own conversations, oldest first.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `conversationId` | `number` | The conversation id. |
| `[tx]` | `object` | 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**
```js
const messages = await store.modelmessage.list(42, 7);
```

99
API.modelvfs.md Normal file
View File

@@ -0,0 +1,99 @@
# modelvfs
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `modelvfs.delete`
*Source: `src/store/modelvfs/delete.js`*
Deletes a virtual file by name for the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `name` | `string` | File name. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{deleted: boolean}>`
## `modelvfs.list`
*Source: `src/store/modelvfs/list.js`*
Lists virtual files for the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, company_id: number, name: string, mime: string, meta: any, created_at: string, updated_at: string}>>`
## `modelvfs.read`
*Source: `src/store/modelvfs/read.js`*
Reads a virtual file by name for the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `name` | `string` | File name. |
| `[tx]` | `object` | 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'
## `modelvfs.reset`
*Source: `src/store/modelvfs/reset.js`*
Deletes all virtual files for the selected company (reset VFS).
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{deletedCount: number}>`
## `modelvfs.write`
*Source: `src/store/modelvfs/write.js`*
Writes (inserts or overwrites) a virtual file for the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | |
| `input.name` | `string` | File path/name (e.g. 'candidate.avif', 'report.json'). |
| `[input.mime='application/octet-stream']` | `string` | Mime type. |
| `input.content` | `Buffer|string` | File content buffer or string. |
| `[input.meta]` | `object|string` | Optional metadata object or JSON string. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object>` — The stored file record metadata.

View File

@@ -379,6 +379,11 @@ const CLUSTERS = [
}, },
{ id: 'units', label: 'Units', tables: ['unit'] }, { id: 'units', label: 'Units', tables: ['unit'] },
{ id: 'pos', label: 'POS', tables: ['posterminal', 'posterminalcategory', 'printer', 'labeltemplate'] }, { id: 'pos', label: 'POS', tables: ['posterminal', 'posterminalcategory', 'printer', 'labeltemplate'] },
{
id: 'ai',
label: 'AI & Models',
tables: ['modelconversation', 'modelmessage', 'modelvfs'],
},
{ {
id: 'attributes', id: 'attributes',
label: 'Properties & Attributes', label: 'Properties & Attributes',

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 289 KiB

View File

@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function run() {
console.log('--- Testing store.modelconversation / store.modelmessage ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-modelconv-'));
const provider = new BetterSqlite3Provider({
filename: join(tempDir, 'test.db'),
storageDir: join(tempDir, 'storage'),
});
await provider.initialize();
const store = await new LibDashStore(provider);
try {
const userA = await store.google_auth.add('mc-a@example.com');
const company = await store.company.create({
name: 'MC Corp', iso639: 'de', taxrate: 19, reducedtaxrate: 7, googleAuthId: userA,
});
await store.google_auth.setSelectedCompany(userA, company.id);
const conv = await store.modelconversation.create(userA, {
provider: 'openrouter', model: 'test/model',
});
assert.equal(conv.state, 'idle');
assert.equal(conv.provider, 'openrouter');
await assert.rejects(store.modelconversation.create(userA, { model: 'x' }), /Provider required/);
await assert.rejects(store.modelconversation.create(userA, { provider: 'x' }), /Model required/);
const list = await store.modelconversation.list(userA);
assert.equal(list.length, 1);
assert.deepEqual(await store.modelmessage.list(userA, conv.id), []);
const m1 = await store.modelmessage.add(userA, conv.id, { message: 'hello' });
assert.equal(m1.role, 'user');
assert.equal(m1.conversation_id, conv.id);
await assert.rejects(store.modelmessage.add(userA, conv.id, { message: ' ' }), /Message required/);
await assert.rejects(store.modelmessage.add(userA, 9999, { message: 'x' }), /Conversation not found/);
// Non-idle conversation refuses user messages but allows assistant messages
const busyConv = await store.modelconversation.setState(userA, conv.id, 'busy');
assert.equal(busyConv.state, 'busy');
const fetchedBusy = await store.modelconversation.get(userA, conv.id);
assert.equal(fetchedBusy.state, 'busy');
await assert.rejects(
store.modelmessage.add(userA, conv.id, { message: 'again' }),
/Conversation is not idle/,
);
// Assistant message can be added while busy
const mAssistant = await store.modelmessage.add(userA, conv.id, { message: 'echo: hello', role: 'assistant' });
assert.equal(mAssistant.role, 'assistant');
assert.equal(mAssistant.message, 'echo: hello');
// Back to idle
await store.modelconversation.setState(userA, conv.id, 'idle');
await store.modelmessage.add(userA, conv.id, { message: 'second' });
const msgs = await store.modelmessage.list(userA, conv.id);
assert.deepEqual(msgs.map((m) => m.message), ['hello', 'echo: hello', 'second']);
assert.deepEqual(msgs.map((m) => m.role), ['user', 'assistant', 'user']);
// Other user cannot see or write
const userB = await store.google_auth.add('mc-b@example.com');
await store.companyuser.add(company.id, userB);
await store.google_auth.setSelectedCompany(userB, company.id);
assert.equal((await store.modelconversation.list(userB)).length, 0);
await assert.rejects(store.modelmessage.list(userB, conv.id), /Conversation not found/);
await assert.rejects(store.modelmessage.add(userB, conv.id, { message: 'x' }), /Conversation not found/);
console.log('PASS');
} finally {
await provider.close?.();
rmSync(tempDir, { recursive: true, force: true });
}
}
run().catch((err) => {
console.error('FAIL', err);
process.exit(1);
});

View File

@@ -156,6 +156,19 @@ import printerList from './store/printer/list.js';
import printerUpdate from './store/printer/update.js'; import printerUpdate from './store/printer/update.js';
import printerDelete from './store/printer/delete.js'; import printerDelete from './store/printer/delete.js';
import printerOrder from './store/printer/order.js'; import printerOrder from './store/printer/order.js';
import modelconversationCreate from './store/modelconversation/create.js';
import modelconversationGet from './store/modelconversation/get.js';
import modelconversationList from './store/modelconversation/list.js';
import modelconversationSetState from './store/modelconversation/setState.js';
import modelconversationDelete from './store/modelconversation/delete.js';
import modelmessageList from './store/modelmessage/list.js';
import modelmessageAdd from './store/modelmessage/add.js';
import modelvfsWrite from './store/modelvfs/write.js';
import modelvfsRead from './store/modelvfs/read.js';
import modelvfsList from './store/modelvfs/list.js';
import modelvfsDelete from './store/modelvfs/delete.js';
import modelvfsReset from './store/modelvfs/reset.js';
import labeltemplateCreate from './store/labeltemplate/create.js'; import labeltemplateCreate from './store/labeltemplate/create.js';
import labeltemplateGetById from './store/labeltemplate/getById.js'; import labeltemplateGetById from './store/labeltemplate/getById.js';
import labeltemplateList from './store/labeltemplate/list.js'; import labeltemplateList from './store/labeltemplate/list.js';
@@ -449,6 +462,27 @@ export default class LibDashStore {
order: printerOrder(provider), order: printerOrder(provider),
}; };
this.modelconversation = {
create: modelconversationCreate(provider),
get: modelconversationGet(provider),
list: modelconversationList(provider),
setState: modelconversationSetState(provider),
delete: modelconversationDelete(provider),
};
this.modelvfs = {
write: modelvfsWrite(provider),
read: modelvfsRead(provider),
list: modelvfsList(provider),
delete: modelvfsDelete(provider),
reset: modelvfsReset(provider),
};
this.modelmessage = {
list: modelmessageList(provider),
add: modelmessageAdd(provider),
};
this.labeltemplate = { this.labeltemplate = {
create: labeltemplateCreate(provider), create: labeltemplateCreate(provider),
getById: labeltemplateGetById(provider), getById: labeltemplateGetById(provider),

View File

@@ -8,6 +8,8 @@ export const TABLES = [
'manifestattachment', 'manifestattachment',
'labeltemplate', 'labeltemplate',
'file', 'file',
'modelmessage',
'modelconversation',
'companyuser', 'companyuser',
'syncpos', 'syncpos',
'companysetting', 'companysetting',

View File

@@ -0,0 +1,79 @@
export const MODELCONVERSATION_COLUMNS = `
id,
companyuser_id,
provider,
model,
created_at,
updated_at,
state
`;
export const LIST_BY_COMPANYUSER = `
SELECT
${MODELCONVERSATION_COLUMNS}
FROM modelconversation
WHERE companyuser_id = @companyuserId
ORDER BY updated_at DESC, id DESC
`;
export const GET_BY_COMPANYUSER_AND_ID = `
SELECT
${MODELCONVERSATION_COLUMNS}
FROM modelconversation
WHERE id = @id AND companyuser_id = @companyuserId
`;
export const CREATE = `
INSERT INTO modelconversation (
companyuser_id,
provider,
model
)
OUTPUT
INSERTED.id,
INSERTED.companyuser_id,
INSERTED.provider,
INSERTED.model,
INSERTED.created_at,
INSERTED.updated_at,
INSERTED.state
VALUES (
@companyuserId,
@provider,
@model
)
`;
/** Atomic idle guard + touch: affects 0 rows unless the conversation is idle. */
export const TOUCH_IF_IDLE = `
UPDATE modelconversation
SET updated_at = SYSUTCDATETIME()
WHERE id = @id AND companyuser_id = @companyuserId AND state = 'idle'
`;
/** Unconditional touch (used when appending assistant/system messages or updating activity). */
export const TOUCH = `
UPDATE modelconversation
SET updated_at = SYSUTCDATETIME()
WHERE id = @id AND companyuser_id = @companyuserId
`;
/** Updates conversation state ('idle' | 'busy') and touches updated_at. */
export const SET_STATE = `
UPDATE modelconversation
SET state = @state, updated_at = SYSUTCDATETIME()
OUTPUT
INSERTED.id,
INSERTED.companyuser_id,
INSERTED.provider,
INSERTED.model,
INSERTED.created_at,
INSERTED.updated_at,
INSERTED.state
WHERE id = @id AND companyuser_id = @companyuserId
`;
export const DELETE_BY_COMPANYUSER_AND_ID = `
DELETE FROM modelconversation
WHERE id = @id AND companyuser_id = @companyuserId
`;

View File

@@ -0,0 +1,34 @@
export const MODELMESSAGE_COLUMNS = `
id,
conversation_id,
created_at,
role,
message
`;
export const LIST_BY_CONVERSATION = `
SELECT
${MODELMESSAGE_COLUMNS}
FROM modelmessage
WHERE conversation_id = @conversationId
ORDER BY id ASC
`;
export const CREATE = `
INSERT INTO modelmessage (
conversation_id,
role,
message
)
OUTPUT
INSERTED.id,
INSERTED.conversation_id,
INSERTED.created_at,
INSERTED.role,
INSERTED.message
VALUES (
@conversationId,
@role,
@message
)
`;

View File

@@ -0,0 +1,63 @@
export const MODELVFS_COLUMNS = `
id,
company_id,
name,
mime,
meta,
created_at,
updated_at
`;
export const GET_BY_COMPANY_AND_NAME = `
SELECT
${MODELVFS_COLUMNS},
content
FROM modelvfs
WHERE company_id = @companyId AND name = @name
`;
export const GET_META_BY_COMPANY_AND_NAME = `
SELECT
${MODELVFS_COLUMNS}
FROM modelvfs
WHERE company_id = @companyId AND name = @name
`;
export const LIST_BY_COMPANY = `
SELECT
${MODELVFS_COLUMNS}
FROM modelvfs
WHERE company_id = @companyId
ORDER BY name ASC
`;
export const UPSERT = `
MERGE modelvfs AS target
USING (SELECT @companyId AS company_id, @name AS name) AS src
ON target.company_id = src.company_id AND target.name = src.name
WHEN MATCHED THEN
UPDATE SET mime = @mime, content = @content, meta = @meta, updated_at = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (company_id, name, mime, content, meta)
VALUES (@companyId, @name, @mime, @content, @meta)
OUTPUT
INSERTED.id,
INSERTED.company_id,
INSERTED.name,
INSERTED.mime,
INSERTED.meta,
INSERTED.created_at,
INSERTED.updated_at;
`;
export const DELETE_BY_COMPANY_AND_NAME = `
DELETE FROM modelvfs
OUTPUT DELETED.id
WHERE company_id = @companyId AND name = @name
`;
export const DELETE_ALL_BY_COMPANY = `
DELETE FROM modelvfs
OUTPUT DELETED.id
WHERE company_id = @companyId
`;

View File

@@ -771,6 +771,60 @@ BEGIN
END END
`; `;
export const INIT_MODELCONVERSATION = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'modelconversation')
BEGIN
CREATE TABLE modelconversation (
id INT IDENTITY(1,1) PRIMARY KEY,
companyuser_id INT NOT NULL,
provider NVARCHAR(100) NOT NULL,
model NVARCHAR(255) NOT NULL,
created_at DATETIME2 NOT NULL CONSTRAINT DF_modelconversation_created_at DEFAULT SYSUTCDATETIME(),
updated_at DATETIME2 NOT NULL CONSTRAINT DF_modelconversation_updated_at DEFAULT SYSUTCDATETIME(),
state NVARCHAR(20) NOT NULL CONSTRAINT DF_modelconversation_state DEFAULT 'idle',
CONSTRAINT FK_modelconversation_companyuser FOREIGN KEY (companyuser_id) REFERENCES companyuser(id) ON DELETE CASCADE,
CONSTRAINT CK_modelconversation_state CHECK (state IN ('idle', 'busy'))
);
CREATE INDEX IX_modelconversation_companyuser ON modelconversation (companyuser_id, updated_at);
END
`;
export const INIT_MODELVFS = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'modelvfs')
BEGIN
CREATE TABLE modelvfs (
id INT IDENTITY(1,1) PRIMARY KEY,
company_id INT NOT NULL,
name NVARCHAR(255) NOT NULL,
mime NVARCHAR(100) NOT NULL,
content VARBINARY(MAX) NOT NULL,
meta NVARCHAR(MAX),
created_at DATETIME2 NOT NULL CONSTRAINT DF_modelvfs_created_at DEFAULT SYSUTCDATETIME(),
updated_at DATETIME2 NOT NULL CONSTRAINT DF_modelvfs_updated_at DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_modelvfs_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE,
CONSTRAINT UQ_modelvfs_company_name UNIQUE (company_id, name)
);
CREATE INDEX IX_modelvfs_company_name ON modelvfs (company_id, name);
END
`;
export const INIT_MODELMESSAGE = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'modelmessage')
BEGIN
CREATE TABLE modelmessage (
id INT IDENTITY(1,1) PRIMARY KEY,
conversation_id INT NOT NULL,
created_at DATETIME2 NOT NULL CONSTRAINT DF_modelmessage_created_at DEFAULT SYSUTCDATETIME(),
role NVARCHAR(20) NOT NULL,
message NVARCHAR(MAX) NOT NULL,
CONSTRAINT FK_modelmessage_conversation FOREIGN KEY (conversation_id) REFERENCES modelconversation(id) ON DELETE CASCADE,
CONSTRAINT CK_modelmessage_role CHECK (role IN ('system', 'user', 'assistant', 'tool', 'reasoning'))
);
CREATE INDEX IX_modelmessage_conversation ON modelmessage (conversation_id, id);
END
`;
export const INIT_SYNCPOS = ` export const INIT_SYNCPOS = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'syncpos') IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'syncpos')
BEGIN BEGIN
@@ -1087,6 +1141,9 @@ export const SCHEMA_SCRIPTS = [
INIT_GET_NEXT_NUMBER_WITH_PREFIX_AND_PADDING, INIT_GET_NEXT_NUMBER_WITH_PREFIX_AND_PADDING,
INIT_PEEK_NEXT_NUMBER_WITH_PREFIX_AND_PADDING, INIT_PEEK_NEXT_NUMBER_WITH_PREFIX_AND_PADDING,
INIT_COMPANYUSER, INIT_COMPANYUSER,
INIT_MODELCONVERSATION,
INIT_MODELMESSAGE,
INIT_MODELVFS,
INIT_ARTICLEHISTORY, INIT_ARTICLEHISTORY,
INIT_ARTICLETOMBSTONE, INIT_ARTICLETOMBSTONE,
INIT_ENTITYHISTORY, INIT_ENTITYHISTORY,

View File

@@ -180,6 +180,31 @@ export class BetterSqlite3Executor {
await safeDeleteByHash(join(this.storageDir, 'thumbavif'), hash); await safeDeleteByHash(join(this.storageDir, 'thumbavif'), hash);
} }
async writeVfsContent(id, buffer) {
if (!this.storageDir || !id) return;
await safeWrite(join(this.storageDir, 'vfs', String(id)), buffer);
}
async readVfsContent(id) {
if (!this.storageDir || !id) return null;
try {
return await fsPromises.readFile(join(this.storageDir, 'vfs', String(id)));
} catch (err) {
if (err.code === 'ENOENT') return null;
throw err;
}
}
async deleteVfsContent(id) {
if (!this.storageDir || !id) return;
try {
await fsPromises.unlink(join(this.storageDir, 'vfs', String(id)));
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
}
async deletePic(hash) { async deletePic(hash) {
if (!this.storageDir || !hash) return; if (!this.storageDir || !hash) return;
await safeDeleteByHash(join(this.storageDir, 'fullbinary'), hash); await safeDeleteByHash(join(this.storageDir, 'fullbinary'), hash);
@@ -188,7 +213,7 @@ export class BetterSqlite3Executor {
async purgeFiles() { async purgeFiles() {
if (!this.storageDir) return; if (!this.storageDir) return;
for (const sub of ['content', 'thumbavif', 'fullbinary', 'thumbbinary']) { for (const sub of ['content', 'thumbavif', 'fullbinary', 'thumbbinary', 'vfs']) {
const dir = join(this.storageDir, sub); const dir = join(this.storageDir, sub);
try { try {
const files = await fsPromises.readdir(dir); const files = await fsPromises.readdir(dir);
@@ -276,7 +301,7 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
super(db, storageDir); super(db, storageDir);
if (this.storageDir) { if (this.storageDir) {
for (const sub of ['content', 'thumbavif', 'fullbinary', 'thumbbinary']) { for (const sub of ['content', 'thumbavif', 'fullbinary', 'thumbbinary', 'vfs']) {
mkdirSync(join(this.storageDir, sub), { recursive: true }); mkdirSync(join(this.storageDir, sub), { recursive: true });
} }
} }
@@ -316,6 +341,9 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
runner.readPicFull = executor.readPicFull.bind(executor); runner.readPicFull = executor.readPicFull.bind(executor);
runner.writePicThumb = executor.writePicThumb.bind(executor); runner.writePicThumb = executor.writePicThumb.bind(executor);
runner.readPicThumb = executor.readPicThumb.bind(executor); runner.readPicThumb = executor.readPicThumb.bind(executor);
runner.writeVfsContent = executor.writeVfsContent.bind(executor);
runner.readVfsContent = executor.readVfsContent.bind(executor);
runner.deleteVfsContent = executor.deleteVfsContent.bind(executor);
runner.deleteFile = executor.deleteFile.bind(executor); runner.deleteFile = executor.deleteFile.bind(executor);
runner.deletePic = executor.deletePic.bind(executor); runner.deletePic = executor.deletePic.bind(executor);
runner.purgeFiles = executor.purgeFiles.bind(executor); runner.purgeFiles = executor.purgeFiles.bind(executor);

View File

@@ -0,0 +1,14 @@
import * as source from '../../mssql/queries/modelconversation.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'modelconversation');
export const {
CREATE,
DELETE_BY_COMPANYUSER_AND_ID,
GET_BY_COMPANYUSER_AND_ID,
LIST_BY_COMPANYUSER,
MODELCONVERSATION_COLUMNS,
SET_STATE,
TOUCH,
TOUCH_IF_IDLE,
} = queries;

View File

@@ -0,0 +1,5 @@
import * as source from '../../mssql/queries/modelmessage.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'modelmessage');
export const { CREATE, LIST_BY_CONVERSATION } = queries;

View File

@@ -0,0 +1,38 @@
import * as source from '../../mssql/queries/modelvfs.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'modelvfs');
export const {
MODELVFS_COLUMNS,
GET_META_BY_COMPANY_AND_NAME,
LIST_BY_COMPANY,
} = queries;
export const GET_BY_COMPANY_AND_NAME = `
SELECT
${MODELVFS_COLUMNS}
FROM modelvfs
WHERE company_id = @companyId AND name = @name
`;
export const UPSERT = `
INSERT INTO modelvfs (company_id, name, mime, meta)
VALUES (@companyId, @name, @mime, @meta)
ON CONFLICT (company_id, name) DO UPDATE SET
mime = excluded.mime,
meta = excluded.meta,
updated_at = (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
RETURNING id, company_id, name, mime, meta, created_at, updated_at
`;
export const DELETE_BY_COMPANY_AND_NAME = `
DELETE FROM modelvfs
WHERE company_id = @companyId AND name = @name
RETURNING id
`;
export const DELETE_ALL_BY_COMPANY = `
DELETE FROM modelvfs
WHERE company_id = @companyId
RETURNING id
`;

View File

@@ -483,6 +483,48 @@ CREATE TABLE IF NOT EXISTS companyuser (
); );
`; `;
export const INIT_MODELCONVERSATION = `
CREATE TABLE IF NOT EXISTS modelconversation (
id INTEGER PRIMARY KEY,
companyuser_id INTEGER NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
updated_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
state TEXT NOT NULL DEFAULT 'idle' CHECK (state IN ('idle', 'busy')),
FOREIGN KEY (companyuser_id) REFERENCES companyuser(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS IX_modelconversation_companyuser ON modelconversation (companyuser_id, updated_at);
`;
export const INIT_MODELVFS = `
CREATE TABLE IF NOT EXISTS modelvfs (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL,
name TEXT NOT NULL,
mime TEXT NOT NULL,
meta TEXT,
created_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
updated_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE,
UNIQUE (company_id, name)
);
CREATE INDEX IF NOT EXISTS IX_modelvfs_company_name ON modelvfs (company_id, name);
`;
export const INIT_MODELMESSAGE = `
CREATE TABLE IF NOT EXISTS modelmessage (
id INTEGER PRIMARY KEY,
conversation_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant', 'tool', 'reasoning')),
message TEXT NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES modelconversation(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS IX_modelmessage_conversation ON modelmessage (conversation_id, id);
`;
export const INIT_ARTICLEHISTORY = ` export const INIT_ARTICLEHISTORY = `
CREATE TABLE IF NOT EXISTS articlehistory ( CREATE TABLE IF NOT EXISTS articlehistory (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@@ -770,6 +812,9 @@ export const SCHEMA_SCRIPTS = [
INIT_LEDGER, INIT_LEDGER,
INIT_NUMBERS, INIT_NUMBERS,
INIT_COMPANYUSER, INIT_COMPANYUSER,
INIT_MODELCONVERSATION,
INIT_MODELMESSAGE,
INIT_MODELVFS,
INIT_ARTICLEHISTORY, INIT_ARTICLEHISTORY,
INIT_ARTICLETOMBSTONE, INIT_ARTICLETOMBSTONE,
INIT_ENTITYHISTORY, INIT_ENTITYHISTORY,

View File

@@ -41,7 +41,7 @@ export default function createUnderParent(provider) {
* const article = await store.article.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Cola' }); * const article = await store.article.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Cola' });
* // => { id, parentId, name, sku, track_stock } * // => { 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 rawTrack = track_stock ?? trackStock;
const finalTrackStock = rawTrack == null ? 1 : (rawTrack ? 1 : 0); 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 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 }); const slug = await resolveSlug(runner, { companyId, languageId, name });
let article; 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.readPicThumb = base.readPicThumb?.bind(base);
run.deleteFile = base.deleteFile?.bind(base); run.deleteFile = base.deleteFile?.bind(base);
run.deletePic = base.deletePic?.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; return run;
} }