Refactor workflow management and validation

- Updated `list_workflows.hpp` to replace `is_active` with `enabled` filter and added `tenant_id` filter.
- Modified sorting logic for workflows based on `created_at` to handle optional values.
- Enhanced `update_workflow.hpp` to include new fields: `nodes`, `edges`, `enabled`, `version`, and `created_at`.
- Removed obsolete `isValidWorkflowTrigger` validation function from `workflow_validation.hpp`.
- Adjusted unit tests in `client_test.cpp` to reflect changes in workflow and page management, including renaming fields and updating assertions.
- Updated Prisma schema in `schema.prisma` to align with new workflow and component models, including changes to data types and attributes.
- Introduced a new script `gen_prisma_schema.js` for generating Prisma schema from DBAL API schemas to maintain synchronization.
This commit is contained in:
2026-01-07 13:43:37 +00:00
parent 8e5930cd44
commit 445f4f4028
24 changed files with 890 additions and 460 deletions

View File

@@ -0,0 +1,246 @@
import type { AdapterCapabilities, DBALAdapter } from '../adapter'
import type { ListOptions, ListResult } from '../../core/foundation/types'
import { DBALError } from '../../core/foundation/errors'
const ID_FIELDS: Record<string, string> = {
Credential: 'username',
InstalledPackage: 'packageId',
PackageData: 'packageId',
}
const resolveIdField = (entity: string, data?: Record<string, unknown>): string => {
if (ID_FIELDS[entity]) {
return ID_FIELDS[entity]
}
if (data && typeof data.id === 'string' && data.id.trim().length > 0) {
return 'id'
}
return 'id'
}
const getRecordId = (entity: string, data: Record<string, unknown>): string => {
const idField = resolveIdField(entity, data)
const value = data[idField]
if (typeof value !== 'string' || value.trim().length === 0) {
throw DBALError.validationError(`${entity} ${idField} is required`, [
{ field: idField, error: `${idField} is required` },
])
}
return value
}
const applyFilter = (
records: Record<string, unknown>[],
filter?: Record<string, unknown>,
): Record<string, unknown>[] => {
if (!filter || Object.keys(filter).length === 0) {
return records
}
return records.filter((record) =>
Object.entries(filter).every(([key, value]) => record[key] === value),
)
}
const applySort = (
records: Record<string, unknown>[],
sort?: Record<string, 'asc' | 'desc'>,
): Record<string, unknown>[] => {
if (!sort || Object.keys(sort).length === 0) {
return records
}
const [key, direction] = Object.entries(sort)[0]
return [...records].sort((left, right) => {
const a = left[key]
const b = right[key]
if (typeof a === 'string' && typeof b === 'string') {
return direction === 'asc' ? a.localeCompare(b) : b.localeCompare(a)
}
if (typeof a === 'number' && typeof b === 'number') {
return direction === 'asc' ? a - b : b - a
}
if (typeof a === 'bigint' && typeof b === 'bigint') {
return direction === 'asc' ? Number(a - b) : Number(b - a)
}
if (typeof a === 'boolean' && typeof b === 'boolean') {
return direction === 'asc' ? Number(a) - Number(b) : Number(b) - Number(a)
}
return 0
})
}
export class MemoryAdapter implements DBALAdapter {
private store: Map<string, Map<string, Record<string, unknown>>> = new Map()
private getEntityStore(entity: string): Map<string, Record<string, unknown>> {
const existing = this.store.get(entity)
if (existing) return existing
const created = new Map<string, Record<string, unknown>>()
this.store.set(entity, created)
return created
}
async create(entity: string, data: Record<string, unknown>): Promise<unknown> {
const entityStore = this.getEntityStore(entity)
const id = getRecordId(entity, data)
if (entityStore.has(id)) {
throw DBALError.conflict(`${entity} already exists: ${id}`)
}
const record = { ...data }
entityStore.set(id, record)
return record
}
async read(entity: string, id: string): Promise<unknown | null> {
const entityStore = this.getEntityStore(entity)
return entityStore.get(id) ?? null
}
async update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
const entityStore = this.getEntityStore(entity)
const existing = entityStore.get(id)
if (!existing) {
throw DBALError.notFound(`${entity} not found: ${id}`)
}
const record = { ...existing, ...data }
entityStore.set(id, record)
return record
}
async delete(entity: string, id: string): Promise<boolean> {
const entityStore = this.getEntityStore(entity)
return entityStore.delete(id)
}
async list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
const entityStore = this.getEntityStore(entity)
const page = options?.page ?? 1
const limit = options?.limit ?? 20
const filtered = applyFilter(Array.from(entityStore.values()), options?.filter)
const sorted = applySort(filtered, options?.sort)
const start = (page - 1) * limit
const data = sorted.slice(start, start + limit)
return {
data,
total: filtered.length,
page,
limit,
hasMore: start + limit < filtered.length,
}
}
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
const entityStore = this.getEntityStore(entity)
const result = applyFilter(Array.from(entityStore.values()), filter)
return result[0] ?? null
}
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
return this.findFirst(entity, { [field]: value })
}
async upsert(
entity: string,
uniqueField: string,
uniqueValue: unknown,
createData: Record<string, unknown>,
updateData: Record<string, unknown>,
): Promise<unknown> {
const entityStore = this.getEntityStore(entity)
const existing = Array.from(entityStore.entries()).find(([, record]) => record[uniqueField] === uniqueValue)
if (existing) {
const [id, record] = existing
const next = { ...record, ...updateData }
entityStore.set(id, next)
return next
}
const payload = { ...createData, [uniqueField]: uniqueValue }
return this.create(entity, payload)
}
async updateByField(
entity: string,
field: string,
value: unknown,
data: Record<string, unknown>,
): Promise<unknown> {
const entityStore = this.getEntityStore(entity)
const entry = Array.from(entityStore.entries()).find(([, record]) => record[field] === value)
if (!entry) {
throw DBALError.notFound(`${entity} not found`)
}
const [id, record] = entry
const next = { ...record, ...data }
entityStore.set(id, next)
return next
}
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
const entityStore = this.getEntityStore(entity)
const entry = Array.from(entityStore.entries()).find(([, record]) => record[field] === value)
if (!entry) {
return false
}
return entityStore.delete(entry[0])
}
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
const entityStore = this.getEntityStore(entity)
const candidates = Array.from(entityStore.entries()).filter(([, record]) =>
Object.entries(filter ?? {}).every(([key, value]) => record[key] === value),
)
let deleted = 0
for (const [id] of candidates) {
if (entityStore.delete(id)) {
deleted += 1
}
}
return deleted
}
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
if (!data || data.length === 0) return 0
const entityStore = this.getEntityStore(entity)
const records = data.map((item) => ({ id: getRecordId(entity, item), record: { ...item } }))
for (const { id } of records) {
if (entityStore.has(id)) {
throw DBALError.conflict(`${entity} already exists: ${id}`)
}
}
records.forEach(({ id, record }) => {
entityStore.set(id, record)
})
return records.length
}
async updateMany(
entity: string,
filter: Record<string, unknown>,
data: Record<string, unknown>,
): Promise<number> {
const entityStore = this.getEntityStore(entity)
const entries = Array.from(entityStore.entries())
const matches = entries.filter(([, record]) =>
Object.entries(filter).every(([key, value]) => record[key] === value),
)
matches.forEach(([id, record]) => {
entityStore.set(id, { ...record, ...data })
})
return matches.length
}
getCapabilities(): Promise<AdapterCapabilities> {
return Promise.resolve({
transactions: false,
joins: false,
fullTextSearch: false,
ttl: false,
jsonQueries: false,
aggregations: false,
relations: false,
})
}
async close(): Promise<void> {
this.store.clear()
}
}

View File

@@ -7,6 +7,7 @@ import type { DBALConfig } from '../../runtime/config'
import type { DBALAdapter } from '../../adapters/adapter'
import { DBALError } from '../foundation/errors'
import { PrismaAdapter, PostgresAdapter, MySQLAdapter } from '../../adapters/prisma'
import { MemoryAdapter } from '../../adapters/memory'
import { ACLAdapter } from '../../adapters/acl-adapter'
import { WebSocketBridge } from '../../bridges/websocket-bridge'
@@ -28,6 +29,9 @@ export const createAdapter = (config: DBALConfig): DBALAdapter => {
}
)
break
case 'memory':
baseAdapter = new MemoryAdapter()
break
case 'postgres':
baseAdapter = new PostgresAdapter(
config.database?.url,

View File

@@ -6,7 +6,7 @@ export const validateClientConfig = (config: DBALConfig): DBALConfig => {
throw DBALError.validationError('Adapter type must be specified', [])
}
if (config.mode !== 'production' && !config.database?.url) {
if (config.mode !== 'production' && config.adapter !== 'memory' && !config.database?.url) {
throw DBALError.validationError('Database URL must be specified for non-production mode', [])
}

View File

@@ -19,8 +19,8 @@ export const extendSession = async (
return { success: false, error: { code: 'VALIDATION_ERROR', message: idErrors[0] || 'Invalid ID' } }
}
if (additionalSeconds <= 0) {
return { success: false, error: { code: 'VALIDATION_ERROR', message: 'Additional seconds must be positive' } }
if (additionalSeconds <= 0 || !Number.isInteger(additionalSeconds)) {
return { success: false, error: { code: 'VALIDATION_ERROR', message: 'Additional seconds must be a positive integer' } }
}
const session = store.sessions.get(id)

View File

@@ -1,6 +1,6 @@
export interface DBALConfig {
mode: 'development' | 'production'
adapter: 'prisma' | 'sqlite' | 'mongodb' | 'postgres' | 'mysql'
adapter: 'prisma' | 'sqlite' | 'mongodb' | 'postgres' | 'mysql' | 'memory'
tenantId?: string
endpoint?: string
auth?: {
@@ -24,7 +24,7 @@ export interface DBALConfig {
export interface User {
id: string
username: string
role: 'user' | 'admin' | 'god' | 'supergod'
role: 'public' | 'user' | 'moderator' | 'admin' | 'god' | 'supergod'
}
export interface Session {

View File

@@ -9,7 +9,8 @@ describe('lua script in-memory operations', () => {
const userResult = await createUser(store, {
username: 'lua_owner',
email: 'lua_owner@example.com'
email: 'lua_owner@example.com',
role: 'user'
})
expect(userResult.success).toBe(true)
@@ -55,7 +56,8 @@ describe('lua script in-memory operations', () => {
const userResult = await createUser(store, {
username: 'lua_owner',
email: 'lua_owner@example.com'
email: 'lua_owner@example.com',
role: 'user'
})
expect(userResult.success).toBe(true)

View File

@@ -8,7 +8,8 @@ describe('session in-memory operations', () => {
const store = createInMemoryStore()
const userResult = await createUser(store, {
username: 'session_owner',
email: 'session_owner@example.com'
email: 'session_owner@example.com',
role: 'user'
})
expect(userResult.success).toBe(true)
@@ -58,7 +59,8 @@ describe('session in-memory operations', () => {
const store = createInMemoryStore()
const userResult = await createUser(store, {
username: 'session_owner',
email: 'session_owner@example.com'
email: 'session_owner@example.com',
role: 'user'
})
expect(userResult.success).toBe(true)

View File

@@ -164,38 +164,45 @@ struct MoveComponentInput {
int order = 0;
};
struct Workflow {
std::string id;
std::string name;
std::optional<std::string> description;
std::string trigger;
Json trigger_config;
Json steps;
bool is_active;
std::string created_by;
Timestamp created_at;
Timestamp updated_at;
};
struct CreateWorkflowInput {
std::string name;
std::optional<std::string> description;
std::string trigger;
Json trigger_config;
Json steps;
bool is_active = true;
std::string created_by;
};
struct UpdateWorkflowInput {
std::optional<std::string> name;
std::optional<std::string> description;
std::optional<std::string> trigger;
std::optional<Json> trigger_config;
std::optional<Json> steps;
std::optional<bool> is_active;
std::optional<std::string> created_by;
};
struct Workflow {
std::string id;
std::optional<std::string> tenant_id;
std::string name;
std::optional<std::string> description;
std::string nodes;
std::string edges;
bool enabled;
int version = 1;
std::optional<Timestamp> created_at;
std::optional<Timestamp> updated_at;
std::optional<std::string> created_by;
};
struct CreateWorkflowInput {
std::optional<std::string> tenant_id;
std::string name;
std::optional<std::string> description;
std::string nodes;
std::string edges;
bool enabled;
int version = 1;
std::optional<Timestamp> created_at;
std::optional<Timestamp> updated_at;
std::optional<std::string> created_by;
};
struct UpdateWorkflowInput {
std::optional<std::string> tenant_id;
std::optional<std::string> name;
std::optional<std::string> description;
std::optional<std::string> nodes;
std::optional<std::string> edges;
std::optional<bool> enabled;
std::optional<int> version;
std::optional<Timestamp> created_at;
std::optional<Timestamp> updated_at;
std::optional<std::string> created_by;
};
struct Session {
std::string id;

View File

@@ -319,17 +319,17 @@ public:
return Error::notImplemented("SQL adapter listLuaScripts");
}
Result<Package> createPackage(const CreatePackageInput& input) override {
Result<InstalledPackage> createPackage(const CreatePackageInput& input) override {
(void)input;
return Error::notImplemented("SQL adapter createPackage");
}
Result<Package> getPackage(const std::string& id) override {
Result<InstalledPackage> getPackage(const std::string& id) override {
(void)id;
return Error::notImplemented("SQL adapter getPackage");
}
Result<Package> updatePackage(const std::string& id, const UpdatePackageInput& input) override {
Result<InstalledPackage> updatePackage(const std::string& id, const UpdatePackageInput& input) override {
(void)id;
(void)input;
return Error::notImplemented("SQL adapter updatePackage");
@@ -340,7 +340,7 @@ public:
return Error::notImplemented("SQL adapter deletePackage");
}
Result<std::vector<Package>> listPackages(const ListOptions& options) override {
Result<std::vector<InstalledPackage>> listPackages(const ListOptions& options) override {
(void)options;
return Error::notImplemented("SQL adapter listPackages");
}

View File

@@ -90,15 +90,16 @@ public:
Result<Workflow> createWorkflow(const CreateWorkflowInput& input) override {
Workflow workflow;
workflow.id = "workflow_" + input.name;
workflow.tenant_id = input.tenant_id;
workflow.name = input.name;
workflow.description = input.description;
workflow.trigger = input.trigger;
workflow.trigger_config = input.trigger_config;
workflow.steps = input.steps;
workflow.is_active = input.is_active;
workflow.nodes = input.nodes;
workflow.edges = input.edges;
workflow.enabled = input.enabled;
workflow.version = input.version;
workflow.created_by = input.created_by;
workflow.created_at = std::chrono::system_clock::now();
workflow.updated_at = workflow.created_at;
workflow.created_at = input.created_at.value_or(std::chrono::system_clock::now());
workflow.updated_at = input.updated_at.value_or(workflow.created_at);
return Result<Workflow>(workflow);
}

View File

@@ -225,15 +225,15 @@ Result<std::vector<LuaScript>> Client::searchLuaScripts(const std::string& query
return entities::lua_script::search(getStore(), query, created_by, limit);
}
Result<Package> Client::createPackage(const CreatePackageInput& input) {
Result<InstalledPackage> Client::createPackage(const CreatePackageInput& input) {
return entities::package::create(getStore(), input);
}
Result<Package> Client::getPackage(const std::string& id) {
Result<InstalledPackage> Client::getPackage(const std::string& id) {
return entities::package::get(getStore(), id);
}
Result<Package> Client::updatePackage(const std::string& id, const UpdatePackageInput& input) {
Result<InstalledPackage> Client::updatePackage(const std::string& id, const UpdatePackageInput& input) {
return entities::package::update(getStore(), id, input);
}
@@ -241,7 +241,7 @@ Result<bool> Client::deletePackage(const std::string& id) {
return entities::package::remove(getStore(), id);
}
Result<std::vector<Package>> Client::listPackages(const ListOptions& options) {
Result<std::vector<InstalledPackage>> Client::listPackages(const ListOptions& options) {
return entities::package::list(getStore(), options);
}

View File

@@ -30,13 +30,13 @@ inline Result<int> batchCreatePackages(InMemoryStore& store, const std::vector<C
for (const auto& id : created_ids) {
auto it = store.packages.find(id);
if (it != store.packages.end()) {
store.package_keys.erase(validation::packageKey(it->second.name, it->second.version));
store.package_keys.erase(validation::packageKey(it->second.package_id));
store.packages.erase(it);
}
}
return result.error();
}
created_ids.push_back(result.value().id);
created_ids.push_back(result.value().package_id);
}
return Result<int>(static_cast<int>(created_ids.size()));

View File

@@ -32,13 +32,13 @@ inline Result<int> batchCreate(InMemoryStore& store, const std::vector<CreatePac
for (const auto& id : created_ids) {
auto it = store.packages.find(id);
if (it != store.packages.end()) {
store.package_keys.erase(validation::packageKey(it->second.name, it->second.version));
store.package_keys.erase(validation::packageKey(it->second.package_id));
store.packages.erase(it);
}
}
return result.error();
}
created_ids.push_back(result.value().id);
created_ids.push_back(result.value().package_id);
}
return Result<int>(static_cast<int>(created_ids.size()));

View File

@@ -27,7 +27,7 @@ inline Result<bool> remove(InMemoryStore& store, const std::string& id) {
return Error::notFound("Package not found: " + id);
}
store.package_keys.erase(validation::packageKey(it->second.name, it->second.version));
store.package_keys.erase(validation::packageKey(it->second.package_id));
store.packages.erase(it);
return Result<bool>(true);

View File

@@ -17,53 +17,55 @@ namespace package {
/**
* List packages with filtering and pagination
*/
inline Result<std::vector<Package>> list(InMemoryStore& store, const ListOptions& options) {
std::vector<Package> packages;
inline Result<std::vector<InstalledPackage>> list(InMemoryStore& store, const ListOptions& options) {
std::vector<InstalledPackage> packages;
for (const auto& [id, package] : store.packages) {
bool matches = true;
if (options.filter.find("name") != options.filter.end()) {
if (package.name != options.filter.at("name")) matches = false;
}
if (options.filter.find("version") != options.filter.end()) {
if (package.version != options.filter.at("version")) matches = false;
}
if (options.filter.find("author") != options.filter.end()) {
if (package.author != options.filter.at("author")) matches = false;
}
if (options.filter.find("is_installed") != options.filter.end()) {
bool filter_installed = options.filter.at("is_installed") == "true";
if (package.is_installed != filter_installed) matches = false;
}
if (options.filter.find("package_id") != options.filter.end()) {
if (package.package_id != options.filter.at("package_id")) matches = false;
}
if (options.filter.find("version") != options.filter.end()) {
if (package.version != options.filter.at("version")) matches = false;
}
if (options.filter.find("tenant_id") != options.filter.end()) {
if (!package.tenant_id.has_value() || package.tenant_id.value() != options.filter.at("tenant_id")) {
matches = false;
}
}
if (options.filter.find("enabled") != options.filter.end()) {
bool filter_enabled = options.filter.at("enabled") == "true";
if (package.enabled != filter_enabled) matches = false;
}
if (matches) {
packages.push_back(package);
}
}
if (options.sort.find("name") != options.sort.end()) {
std::sort(packages.begin(), packages.end(), [](const Package& a, const Package& b) {
return a.name < b.name;
});
} else if (options.sort.find("created_at") != options.sort.end()) {
std::sort(packages.begin(), packages.end(), [](const Package& a, const Package& b) {
return a.created_at < b.created_at;
});
}
if (options.sort.find("package_id") != options.sort.end()) {
std::sort(packages.begin(), packages.end(), [](const InstalledPackage& a, const InstalledPackage& b) {
return a.package_id < b.package_id;
});
} else if (options.sort.find("created_at") != options.sort.end()) {
std::sort(packages.begin(), packages.end(), [](const InstalledPackage& a, const InstalledPackage& b) {
return a.created_at < b.created_at;
});
}
int start = (options.page - 1) * options.limit;
int end = std::min(start + options.limit, static_cast<int>(packages.size()));
if (start < static_cast<int>(packages.size())) {
return Result<std::vector<Package>>(std::vector<Package>(packages.begin() + start, packages.begin() + end));
}
return Result<std::vector<Package>>(std::vector<Package>());
}
if (start < static_cast<int>(packages.size())) {
return Result<std::vector<InstalledPackage>>(std::vector<InstalledPackage>(packages.begin() + start, packages.begin() + end));
}
return Result<std::vector<InstalledPackage>>(std::vector<InstalledPackage>());
}
} // namespace package
} // namespace entities

View File

@@ -17,7 +17,7 @@ namespace package {
/**
* Update an existing package
*/
inline Result<Package> update(InMemoryStore& store, const std::string& id, const UpdatePackageInput& input) {
inline Result<InstalledPackage> update(InMemoryStore& store, const std::string& id, const UpdatePackageInput& input) {
if (id.empty()) {
return Error::validationError("Package ID cannot be empty");
}
@@ -27,67 +27,35 @@ inline Result<Package> update(InMemoryStore& store, const std::string& id, const
return Error::notFound("Package not found: " + id);
}
Package& package = it->second;
std::string next_name = input.name.value_or(package.name);
std::string next_version = input.version.value_or(package.version);
if (!validation::isValidPackageName(next_name)) {
return Error::validationError("Package name must be 1-255 characters");
}
if (!validation::isValidSemver(next_version)) {
return Error::validationError("Version must be valid semver");
}
std::string current_key = validation::packageKey(package.name, package.version);
std::string next_key = validation::packageKey(next_name, next_version);
if (next_key != current_key) {
auto key_it = store.package_keys.find(next_key);
if (key_it != store.package_keys.end() && key_it->second != id) {
return Error::conflict("Package name+version already exists: " + next_key);
}
store.package_keys.erase(current_key);
store.package_keys[next_key] = id;
}
package.name = next_name;
package.version = next_version;
if (input.description.has_value()) {
package.description = input.description.value();
}
if (input.author.has_value()) {
if (input.author.value().empty()) {
return Error::validationError("author is required");
}
package.author = input.author.value();
}
if (input.manifest.has_value()) {
package.manifest = input.manifest.value();
}
if (input.is_installed.has_value()) {
package.is_installed = input.is_installed.value();
}
if (input.installed_at.has_value()) {
package.installed_at = input.installed_at.value();
}
if (input.installed_by.has_value()) {
if (input.installed_by.value().empty()) {
return Error::validationError("installed_by is required");
}
package.installed_by = input.installed_by.value();
}
InstalledPackage& package = it->second;
std::string next_version = input.version.value_or(package.version);
if (!validation::isValidSemver(next_version)) {
return Error::validationError("Version must be valid semver");
}
package.version = next_version;
if (input.tenant_id.has_value()) {
package.tenant_id = input.tenant_id.value();
}
if (input.installed_at.has_value()) {
package.installed_at = input.installed_at.value();
}
if (input.enabled.has_value()) {
package.enabled = input.enabled.value();
}
if (input.config.has_value()) {
package.config = input.config.value();
}
package.updated_at = std::chrono::system_clock::now();
return Result<Package>(package);
}
return Result<InstalledPackage>(package);
}
} // namespace package
} // namespace entities

View File

@@ -1,6 +1,6 @@
/**
* @file get_page.hpp
* @brief Get page by ID or slug operations
* @brief Get page by ID operations
*/
#ifndef DBAL_GET_PAGE_HPP
#define DBAL_GET_PAGE_HPP

View File

@@ -21,28 +21,22 @@ inline Result<Workflow> create(InMemoryStore& store, const CreateWorkflowInput&
if (!validation::isValidWorkflowName(input.name)) {
return Error::validationError("Workflow name must be 1-255 characters");
}
if (!validation::isValidWorkflowTrigger(input.trigger)) {
return Error::validationError("Trigger must be one of manual, schedule, event, webhook");
}
if (input.created_by.empty()) {
return Error::validationError("created_by is required");
}
if (store.workflow_names.find(input.name) != store.workflow_names.end()) {
return Error::conflict("Workflow name already exists: " + input.name);
}
Workflow workflow;
workflow.id = store.generateId("workflow", ++store.workflow_counter);
workflow.name = input.name;
workflow.description = input.description;
workflow.trigger = input.trigger;
workflow.trigger_config = input.trigger_config;
workflow.steps = input.steps;
workflow.is_active = input.is_active;
workflow.created_by = input.created_by;
workflow.created_at = std::chrono::system_clock::now();
workflow.updated_at = workflow.created_at;
if (store.workflow_names.find(input.name) != store.workflow_names.end()) {
return Error::conflict("Workflow name already exists: " + input.name);
}
Workflow workflow;
workflow.id = store.generateId("workflow", ++store.workflow_counter);
workflow.tenant_id = input.tenant_id;
workflow.name = input.name;
workflow.description = input.description;
workflow.nodes = input.nodes;
workflow.edges = input.edges;
workflow.enabled = input.enabled;
workflow.version = input.version;
workflow.created_at = input.created_at.value_or(std::chrono::system_clock::now());
workflow.updated_at = input.updated_at.value_or(workflow.created_at);
workflow.created_by = input.created_by;
store.workflows[workflow.id] = workflow;
store.workflow_names[workflow.name] = workflow.id;

View File

@@ -23,18 +23,22 @@ inline Result<std::vector<Workflow>> list(InMemoryStore& store, const ListOption
for (const auto& [id, workflow] : store.workflows) {
bool matches = true;
if (options.filter.find("is_active") != options.filter.end()) {
bool filter_active = options.filter.at("is_active") == "true";
if (workflow.is_active != filter_active) matches = false;
}
if (options.filter.find("trigger") != options.filter.end()) {
if (workflow.trigger != options.filter.at("trigger")) matches = false;
}
if (options.filter.find("created_by") != options.filter.end()) {
if (workflow.created_by != options.filter.at("created_by")) matches = false;
}
if (options.filter.find("enabled") != options.filter.end()) {
bool filter_enabled = options.filter.at("enabled") == "true";
if (workflow.enabled != filter_enabled) matches = false;
}
if (options.filter.find("tenant_id") != options.filter.end()) {
if (!workflow.tenant_id.has_value() || workflow.tenant_id.value() != options.filter.at("tenant_id")) {
matches = false;
}
}
if (options.filter.find("created_by") != options.filter.end()) {
if (!workflow.created_by.has_value() || workflow.created_by.value() != options.filter.at("created_by")) {
matches = false;
}
}
if (matches) {
workflows.push_back(workflow);
@@ -45,11 +49,12 @@ inline Result<std::vector<Workflow>> list(InMemoryStore& store, const ListOption
std::sort(workflows.begin(), workflows.end(), [](const Workflow& a, const Workflow& b) {
return a.name < b.name;
});
} else if (options.sort.find("created_at") != options.sort.end()) {
std::sort(workflows.begin(), workflows.end(), [](const Workflow& a, const Workflow& b) {
return a.created_at < b.created_at;
});
}
} else if (options.sort.find("created_at") != options.sort.end()) {
std::sort(workflows.begin(), workflows.end(), [](const Workflow& a, const Workflow& b) {
return a.created_at.value_or(std::chrono::system_clock::time_point()) <
b.created_at.value_or(std::chrono::system_clock::time_point());
});
}
int start = (options.page - 1) * options.limit;
int end = std::min(start + options.limit, static_cast<int>(workflows.size()));

View File

@@ -43,37 +43,43 @@ inline Result<Workflow> update(InMemoryStore& store, const std::string& id, cons
workflow.name = input.name.value();
}
if (input.description.has_value()) {
workflow.description = input.description.value();
}
if (input.trigger.has_value()) {
if (!validation::isValidWorkflowTrigger(input.trigger.value())) {
return Error::validationError("Trigger must be one of manual, schedule, event, webhook");
}
workflow.trigger = input.trigger.value();
}
if (input.trigger_config.has_value()) {
workflow.trigger_config = input.trigger_config.value();
}
if (input.steps.has_value()) {
workflow.steps = input.steps.value();
}
if (input.is_active.has_value()) {
workflow.is_active = input.is_active.value();
}
if (input.created_by.has_value()) {
if (input.created_by.value().empty()) {
return Error::validationError("created_by is required");
}
workflow.created_by = input.created_by.value();
}
workflow.updated_at = std::chrono::system_clock::now();
if (input.description.has_value()) {
workflow.description = input.description.value();
}
if (input.nodes.has_value()) {
workflow.nodes = input.nodes.value();
}
if (input.edges.has_value()) {
workflow.edges = input.edges.value();
}
if (input.enabled.has_value()) {
workflow.enabled = input.enabled.value();
}
if (input.version.has_value()) {
workflow.version = input.version.value();
}
if (input.created_by.has_value()) {
workflow.created_by = input.created_by.value();
}
if (input.created_at.has_value()) {
workflow.created_at = input.created_at.value();
}
if (input.updated_at.has_value()) {
workflow.updated_at = input.updated_at.value();
} else {
workflow.updated_at = std::chrono::system_clock::now();
}
if (input.tenant_id.has_value()) {
workflow.tenant_id = input.tenant_id.value();
}
return Result<Workflow>(workflow);
}

View File

@@ -19,14 +19,6 @@ inline bool isValidWorkflowName(const std::string& name) {
return !name.empty() && name.length() <= 255;
}
/**
* Validate workflow trigger type
*/
inline bool isValidWorkflowTrigger(const std::string& trigger) {
static const std::array<std::string, 4> allowed = {"manual", "schedule", "event", "webhook"};
return std::find(allowed.begin(), allowed.end(), trigger) != allowed.end();
}
} // namespace validation
} // namespace dbal

View File

@@ -520,15 +520,17 @@ void test_page_crud() {
// Create page
dbal::CreatePageInput input;
input.slug = "test-page";
input.path = "/test-page";
input.title = "Test Page";
input.description = "A test page";
input.level = 2;
input.is_active = true;
input.component_tree = "{}";
input.requires_auth = false;
input.is_published = true;
auto createResult = client.createPage(input);
assert(createResult.isOk());
assert(createResult.value().slug == "test-page");
assert(createResult.value().path == "/test-page");
std::string pageId = createResult.value().id;
std::cout << " ✓ Page created with ID: " << pageId << std::endl;
@@ -538,11 +540,11 @@ void test_page_crud() {
assert(getResult.value().title == "Test Page");
std::cout << " ✓ Retrieved page by ID" << std::endl;
// Get by slug
auto getBySlugResult = client.getPageByPath("test-page");
// Get by path
auto getBySlugResult = client.getPageByPath("/test-page");
assert(getBySlugResult.isOk());
assert(getBySlugResult.value().id == pageId);
std::cout << " ✓ Retrieved page by slug" << std::endl;
std::cout << " ✓ Retrieved page by path" << std::endl;
// Update page
dbal::UpdatePageInput updateInput;
@@ -570,21 +572,25 @@ void test_page_validation() {
config.database_url = ":memory:";
dbal::Client client(config);
// Invalid slug (uppercase)
// Invalid path (empty)
dbal::CreatePageInput input1;
input1.slug = "Invalid-Slug";
input1.path = "";
input1.title = "Test";
input1.level = 1;
input1.component_tree = "{}";
input1.requires_auth = false;
auto result1 = client.createPage(input1);
assert(result1.isError());
assert(result1.error().code() == dbal::ErrorCode::ValidationError);
std::cout << "Uppercase slug rejected" << std::endl;
std::cout << "Empty path rejected" << std::endl;
// Empty title
dbal::CreatePageInput input2;
input2.slug = "valid-slug";
input2.path = "/valid-path";
input2.title = "";
input2.level = 1;
input2.component_tree = "{}";
input2.requires_auth = false;
auto result2 = client.createPage(input2);
assert(result2.isError());
assert(result2.error().code() == dbal::ErrorCode::ValidationError);
@@ -592,9 +598,11 @@ void test_page_validation() {
// Invalid level
dbal::CreatePageInput input3;
input3.slug = "valid-slug";
input3.path = "/valid-path-2";
input3.title = "Test";
input3.level = 10;
input3.component_tree = "{}";
input3.requires_auth = false;
auto result3 = client.createPage(input3);
assert(result3.isError());
assert(result3.error().code() == dbal::ErrorCode::ValidationError);
@@ -610,20 +618,22 @@ void test_page_search() {
dbal::Client client(config);
dbal::CreatePageInput page1;
page1.slug = "search-page";
page1.path = "/search-page";
page1.title = "Search Page";
page1.level = 1;
page1.layout = {{"row", "one"}};
page1.is_active = true;
page1.component_tree = "{}";
page1.requires_auth = false;
page1.is_published = true;
auto result1 = client.createPage(page1);
assert(result1.isOk());
dbal::CreatePageInput page2;
page2.slug = "other-page";
page2.path = "/other-page";
page2.title = "Other Search";
page2.level = 1;
page2.layout = {{"row", "two"}};
page2.is_active = true;
page2.component_tree = "{}";
page2.requires_auth = false;
page2.is_published = true;
auto result2 = client.createPage(page2);
assert(result2.isOk());
@@ -652,11 +662,12 @@ void test_component_crud() {
dbal::Client client(config);
dbal::CreatePageInput pageInput;
pageInput.slug = "component-page";
pageInput.path = "/component-page";
pageInput.title = "Component Page";
pageInput.level = 1;
pageInput.layout = {{"region", "root"}};
pageInput.is_active = true;
pageInput.component_tree = "{}";
pageInput.requires_auth = false;
pageInput.is_published = true;
auto pageResult = client.createPage(pageInput);
assert(pageResult.isOk());
@@ -664,9 +675,9 @@ void test_component_crud() {
dbal::CreateComponentNodeInput rootInput;
rootInput.page_id = pageId;
rootInput.component_type = "Container";
rootInput.type = "Container";
rootInput.child_ids = "[]";
rootInput.order = 0;
rootInput.props = {{"role", "root"}};
auto rootResult = client.createComponent(rootInput);
assert(rootResult.isOk());
@@ -676,9 +687,9 @@ void test_component_crud() {
dbal::CreateComponentNodeInput childInput;
childInput.page_id = pageId;
childInput.parent_id = rootId;
childInput.component_type = "Button";
childInput.type = "Button";
childInput.child_ids = "[]";
childInput.order = 1;
childInput.props = {{"label", "Click"}};
auto childResult = client.createComponent(childInput);
assert(childResult.isOk());
@@ -688,9 +699,9 @@ void test_component_crud() {
dbal::CreateComponentNodeInput siblingInput;
siblingInput.page_id = pageId;
siblingInput.parent_id = rootId;
siblingInput.component_type = "Text";
siblingInput.type = "Text";
siblingInput.child_ids = "[]";
siblingInput.order = 3;
siblingInput.props = {{"content", "sidebar"}};
auto siblingResult = client.createComponent(siblingInput);
assert(siblingResult.isOk());
@@ -719,12 +730,12 @@ void test_component_crud() {
dbal::ListOptions typeFilter;
typeFilter.filter["pageId"] = pageId;
typeFilter.filter["component_type"] = "Text";
typeFilter.filter["type"] = "Text";
auto typeList = client.listComponents(typeFilter);
assert(typeList.isOk());
assert(!typeList.value().empty());
for (const auto& entry : typeList.value()) {
assert(entry.component_type == "Text");
assert(entry.type == "Text");
}
std::cout << " ✓ Component type filter works" << std::endl;
@@ -742,9 +753,9 @@ void test_component_crud() {
dbal::CreateComponentNodeInput otherRootInput;
otherRootInput.page_id = pageId;
otherRootInput.component_type = "Sidebar";
otherRootInput.type = "Sidebar";
otherRootInput.child_ids = "[]";
otherRootInput.order = 0;
otherRootInput.props = {{"region", "secondary"}};
auto otherRootResult = client.createComponent(otherRootInput);
assert(otherRootResult.isOk());
@@ -785,11 +796,12 @@ void test_component_validation() {
dbal::Client client(config);
dbal::CreatePageInput pageInput;
pageInput.slug = "component-validation";
pageInput.path = "/component-validation";
pageInput.title = "Component Validation";
pageInput.level = 1;
pageInput.layout = {{"mode", "validate"}};
pageInput.is_active = true;
pageInput.component_tree = "{}";
pageInput.requires_auth = false;
pageInput.is_published = true;
auto pageResult = client.createPage(pageInput);
assert(pageResult.isOk());
@@ -797,9 +809,9 @@ void test_component_validation() {
dbal::CreateComponentNodeInput missingPage;
missingPage.page_id = "missing-page";
missingPage.component_type = "Leaf";
missingPage.type = "Leaf";
missingPage.child_ids = "[]";
missingPage.order = 0;
missingPage.props = {{"key", "value"}};
auto missingResult = client.createComponent(missingPage);
assert(missingResult.isError());
assert(missingResult.error().code() == dbal::ErrorCode::NotFound);
@@ -807,9 +819,9 @@ void test_component_validation() {
dbal::CreateComponentNodeInput longType;
longType.page_id = pageId;
longType.component_type = std::string(101, 'x');
longType.type = std::string(101, 'x');
longType.child_ids = "[]";
longType.order = 0;
longType.props = {{"key", "value"}};
auto longResult = client.createComponent(longType);
assert(longResult.isError());
assert(longResult.error().code() == dbal::ErrorCode::ValidationError);
@@ -817,9 +829,9 @@ void test_component_validation() {
dbal::CreateComponentNodeInput badOrder;
badOrder.page_id = pageId;
badOrder.component_type = "Leaf";
badOrder.type = "Leaf";
badOrder.child_ids = "[]";
badOrder.order = -1;
badOrder.props = {{"key", "value"}};
auto orderResult = client.createComponent(badOrder);
assert(orderResult.isError());
assert(orderResult.error().code() == dbal::ErrorCode::ValidationError);
@@ -835,20 +847,21 @@ void test_component_search() {
dbal::Client client(config);
dbal::CreatePageInput pageInput;
pageInput.slug = "component-search";
pageInput.path = "/component-search";
pageInput.title = "Component Search";
pageInput.level = 1;
pageInput.layout = {{"row", "search"}};
pageInput.is_active = true;
pageInput.component_tree = "{}";
pageInput.requires_auth = false;
pageInput.is_published = true;
auto pageResult = client.createPage(pageInput);
assert(pageResult.isOk());
std::string pageId = pageResult.value().id;
dbal::CreateComponentNodeInput targetInput;
targetInput.page_id = pageId;
targetInput.component_type = "SearchButton";
targetInput.type = "SearchButton";
targetInput.child_ids = "[\"find-me\"]";
targetInput.order = 0;
targetInput.props = {{"payload", "find-me"}};
auto targetResult = client.createComponent(targetInput);
assert(targetResult.isOk());
std::string targetId = targetResult.value().id;
@@ -880,20 +893,21 @@ void test_component_children() {
dbal::Client client(config);
dbal::CreatePageInput pageInput;
pageInput.slug = "component-children";
pageInput.path = "/component-children";
pageInput.title = "Component Children";
pageInput.level = 1;
pageInput.layout = {{"tree", "root"}};
pageInput.is_active = true;
pageInput.component_tree = "{}";
pageInput.requires_auth = false;
pageInput.is_published = true;
auto pageResult = client.createPage(pageInput);
assert(pageResult.isOk());
std::string pageId = pageResult.value().id;
dbal::CreateComponentNodeInput rootInput;
rootInput.page_id = pageId;
rootInput.component_type = "Root";
rootInput.type = "Root";
rootInput.child_ids = "[]";
rootInput.order = 0;
rootInput.props = {{"depth", "0"}};
auto rootResult = client.createComponent(rootInput);
assert(rootResult.isOk());
std::string rootId = rootResult.value().id;
@@ -901,9 +915,9 @@ void test_component_children() {
dbal::CreateComponentNodeInput childInput;
childInput.page_id = pageId;
childInput.parent_id = rootId;
childInput.component_type = "Child";
childInput.type = "Child";
childInput.child_ids = "[]";
childInput.order = 0;
childInput.props = {{"depth", "1"}};
auto childResult = client.createComponent(childInput);
assert(childResult.isOk());
std::string childId = childResult.value().id;
@@ -911,9 +925,9 @@ void test_component_children() {
dbal::CreateComponentNodeInput grandchildInput;
grandchildInput.page_id = pageId;
grandchildInput.parent_id = childId;
grandchildInput.component_type = "Grandchild";
grandchildInput.type = "Grandchild";
grandchildInput.child_ids = "[]";
grandchildInput.order = 0;
grandchildInput.props = {{"depth", "2"}};
auto grandchildResult = client.createComponent(grandchildInput);
assert(grandchildResult.isOk());
@@ -936,7 +950,7 @@ void test_component_children() {
auto childChildren = client.getComponentChildren(childId);
assert(childChildren.isOk());
assert(childChildren.value().size() == 1);
assert(childChildren.value()[0].component_type == "Grandchild");
assert(childChildren.value()[0].type == "Grandchild");
std::cout << " ✓ Retrieved grandchildren for child" << std::endl;
auto missing = client.getComponentChildren("nonexistent");
@@ -964,10 +978,9 @@ void test_workflow_crud() {
dbal::CreateWorkflowInput input;
input.name = "workflow-crud";
input.description = "Test workflow";
input.trigger = "schedule";
input.trigger_config = {{"cron", "0 0 * * *"}};
input.steps = {{"step1", "noop"}};
input.is_active = true;
input.nodes = "[]";
input.edges = "[]";
input.enabled = true;
input.created_by = userResult.value().id;
auto createResult = client.createWorkflow(input);
@@ -985,20 +998,20 @@ void test_workflow_crud() {
// Update workflow
dbal::UpdateWorkflowInput updateInput;
updateInput.name = "workflow-crud-updated";
updateInput.is_active = false;
updateInput.enabled = false;
auto updateResult = client.updateWorkflow(workflowId, updateInput);
assert(updateResult.isOk());
assert(updateResult.value().name == "workflow-crud-updated");
assert(updateResult.value().is_active == false);
assert(updateResult.value().enabled == false);
std::cout << " ✓ Workflow updated" << std::endl;
// List workflows
dbal::ListOptions listOptions;
listOptions.filter["is_active"] = "false";
listOptions.filter["enabled"] = "false";
auto listResult = client.listWorkflows(listOptions);
assert(listResult.isOk());
assert(listResult.value().size() >= 1);
std::cout << " ✓ Listed workflows (filtered by is_active=false)" << std::endl;
std::cout << " ✓ Listed workflows (filtered by enabled=false)" << std::endl;
// Delete workflow
auto deleteResult = client.deleteWorkflow(workflowId);
@@ -1025,45 +1038,33 @@ void test_workflow_validation() {
auto userResult = client.createUser(userInput);
assert(userResult.isOk());
// Invalid trigger
// Empty name
dbal::CreateWorkflowInput input1;
input1.name = "invalid-trigger";
input1.trigger = "invalid";
input1.trigger_config = {{"cron", "* * * * *"}};
input1.steps = {{"step1", "noop"}};
input1.name = "";
input1.nodes = "[]";
input1.edges = "[]";
input1.enabled = true;
input1.created_by = userResult.value().id;
auto result1 = client.createWorkflow(input1);
assert(result1.isError());
assert(result1.error().code() == dbal::ErrorCode::ValidationError);
std::cout << " ✓ Invalid trigger rejected" << std::endl;
// Empty name
dbal::CreateWorkflowInput input2;
input2.name = "";
input2.trigger = "manual";
input2.trigger_config = {{"mode", "test"}};
input2.steps = {{"step1", "noop"}};
input2.created_by = userResult.value().id;
auto result2 = client.createWorkflow(input2);
assert(result2.isError());
assert(result2.error().code() == dbal::ErrorCode::ValidationError);
std::cout << " ✓ Empty name rejected" << std::endl;
// Duplicate name
dbal::CreateWorkflowInput input3;
input3.name = "workflow-duplicate";
input3.trigger = "manual";
input3.trigger_config = {{"mode", "test"}};
input3.steps = {{"step1", "noop"}};
dbal::CreateWorkflowInput input2;
input2.name = "workflow-duplicate";
input2.nodes = "[]";
input2.edges = "[]";
input2.enabled = true;
input2.created_by = userResult.value().id;
auto result2 = client.createWorkflow(input2);
assert(result2.isOk());
dbal::CreateWorkflowInput input3 = input2;
input3.created_by = userResult.value().id;
auto result3 = client.createWorkflow(input3);
assert(result3.isOk());
dbal::CreateWorkflowInput input4 = input3;
input4.created_by = userResult.value().id;
auto result4 = client.createWorkflow(input4);
assert(result4.isError());
assert(result4.error().code() == dbal::ErrorCode::Conflict);
assert(result3.isError());
assert(result3.error().code() == dbal::ErrorCode::Conflict);
std::cout << " ✓ Duplicate workflow name rejected" << std::endl;
}
@@ -1333,38 +1334,36 @@ void test_package_crud() {
assert(userResult.isOk());
dbal::CreatePackageInput input;
input.name = "forum";
input.package_id = "forum";
input.version = "1.2.3";
input.description = "Forum package";
input.author = "MetaBuilder";
input.manifest = {{"entry", "index.lua"}};
input.is_installed = false;
input.installed_at = std::chrono::system_clock::now();
input.enabled = false;
input.config = "{\"entry\":\"index.lua\"}";
auto createResult = client.createPackage(input);
assert(createResult.isOk());
std::string packageId = createResult.value().id;
std::string packageId = createResult.value().package_id;
std::cout << " ✓ Package created with ID: " << packageId << std::endl;
auto getResult = client.getPackage(packageId);
assert(getResult.isOk());
assert(getResult.value().name == "forum");
assert(getResult.value().package_id == "forum");
std::cout << " ✓ Retrieved package by ID" << std::endl;
dbal::UpdatePackageInput updateInput;
updateInput.is_installed = true;
updateInput.installed_by = userResult.value().id;
updateInput.enabled = true;
updateInput.installed_at = std::chrono::system_clock::now();
auto updateResult = client.updatePackage(packageId, updateInput);
assert(updateResult.isOk());
assert(updateResult.value().is_installed == true);
assert(updateResult.value().enabled == true);
std::cout << " ✓ Package updated" << std::endl;
dbal::ListOptions listOptions;
listOptions.filter["is_installed"] = "true";
listOptions.filter["enabled"] = "true";
auto listResult = client.listPackages(listOptions);
assert(listResult.isOk());
assert(listResult.value().size() >= 1);
std::cout << " ✓ Listed packages (filtered by is_installed=true)" << std::endl;
std::cout << " ✓ Listed packages (filtered by enabled=true)" << std::endl;
auto deleteResult = client.deletePackage(packageId);
assert(deleteResult.isOk());
@@ -1383,20 +1382,20 @@ void test_package_validation() {
dbal::Client client(config);
dbal::CreatePackageInput input1;
input1.name = "invalid-package";
input1.package_id = "invalid-package";
input1.version = "bad";
input1.author = "MetaBuilder";
input1.manifest = {{"entry", "index.lua"}};
input1.installed_at = std::chrono::system_clock::now();
input1.enabled = false;
auto result1 = client.createPackage(input1);
assert(result1.isError());
assert(result1.error().code() == dbal::ErrorCode::ValidationError);
std::cout << " ✓ Invalid semver rejected" << std::endl;
dbal::CreatePackageInput input2;
input2.name = "duplicate-package";
input2.package_id = "duplicate-package";
input2.version = "1.0.0";
input2.author = "MetaBuilder";
input2.manifest = {{"entry", "index.lua"}};
input2.installed_at = std::chrono::system_clock::now();
input2.enabled = false;
auto result2 = client.createPackage(input2);
assert(result2.isOk());
@@ -1404,7 +1403,7 @@ void test_package_validation() {
auto result3 = client.createPackage(input3);
assert(result3.isError());
assert(result3.error().code() == dbal::ErrorCode::Conflict);
std::cout << " ✓ Duplicate package version rejected" << std::endl;
std::cout << " ✓ Duplicate package ID rejected" << std::endl;
}
void test_package_batch_operations() {
@@ -1417,17 +1416,17 @@ void test_package_batch_operations() {
std::vector<dbal::CreatePackageInput> packages;
dbal::CreatePackageInput package1;
package1.name = "batch-package-1";
package1.package_id = "batch-package-1";
package1.version = "1.0.0";
package1.author = "MetaBuilder";
package1.manifest = {{"entry", "index.lua"}};
package1.installed_at = std::chrono::system_clock::now();
package1.enabled = false;
packages.push_back(package1);
dbal::CreatePackageInput package2;
package2.name = "batch-package-2";
package2.package_id = "batch-package-2";
package2.version = "2.0.0";
package2.author = "MetaBuilder";
package2.manifest = {{"entry", "chat.lua"}};
package2.installed_at = std::chrono::system_clock::now();
package2.enabled = false;
packages.push_back(package2);
auto createResult = client.batchCreatePackages(packages);
@@ -1443,13 +1442,13 @@ void test_package_batch_operations() {
std::vector<dbal::UpdatePackageBatchItem> updates;
dbal::UpdatePackageBatchItem update1;
update1.id = listResult.value()[0].id;
update1.data.is_installed = true;
update1.id = listResult.value()[0].package_id;
update1.data.enabled = true;
updates.push_back(update1);
dbal::UpdatePackageBatchItem update2;
update2.id = listResult.value()[1].id;
update2.data.is_installed = true;
update2.id = listResult.value()[1].package_id;
update2.data.enabled = true;
updates.push_back(update2);
auto updateResult = client.batchUpdatePackages(updates);
@@ -1458,8 +1457,8 @@ void test_package_batch_operations() {
std::cout << " ✓ Batch updated packages" << std::endl;
std::vector<std::string> ids;
ids.push_back(listResult.value()[0].id);
ids.push_back(listResult.value()[1].id);
ids.push_back(listResult.value()[0].package_id);
ids.push_back(listResult.value()[1].package_id);
auto deleteResult = client.batchDeletePackages(ids);
assert(deleteResult.isOk());

View File

@@ -1,59 +1,59 @@
// NOTE: This schema is a simplified dev snapshot for DBAL.
// It is currently out of sync with the app's Prisma schema in `prisma/schema.prisma`.
// TODO: Generate Prisma schemas from DBAL API schemas to keep them aligned.
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(uuid())
username String @unique
email String @unique
role String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workflows Workflow[]
luaScripts LuaScript[]
installedPackages InstalledPackage[]
}
model Credential {
id String @id @default(uuid())
username String @unique
passwordHash String
firstLogin Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Session {
id String @id @default(uuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
lastActivity DateTime @updatedAt
@@index([userId])
@@index([expiresAt])
}
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id
username String @unique
email String @unique
role String
profilePicture String?
bio String?
createdAt BigInt
tenantId String?
isInstanceOwner Boolean @default(false)
passwordChangeTimestamp BigInt?
firstLogin Boolean @default(false)
@@index([tenantId])
@@index([role])
}
model Credential {
username String @id
passwordHash String
}
model Session {
id String @id
userId String
token String @unique
expiresAt BigInt
createdAt BigInt
lastActivity BigInt
ipAddress String?
userAgent String?
@@index([userId])
@@index([expiresAt])
@@index([token])
}
model PageConfig {
id String @id @default(uuid())
id String @id
tenantId String?
packageId String?
path String @unique
path String // Route pattern: /media/jobs, /forum/:id
title String
description String?
icon String?
component String?
componentTree String
componentTree String // JSON: full component tree
level Int
requiresAuth Boolean
requiredRole String?
@@ -62,76 +62,88 @@ model PageConfig {
isPublished Boolean @default(true)
params String?
meta String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
components ComponentNode[]
createdAt BigInt?
updatedAt BigInt?
@@unique([tenantId, path])
@@index([tenantId])
@@index([packageId])
@@index([level])
@@index([isPublished])
@@index([parentPath])
}
model ComponentNode {
id String @id @default(uuid())
pageId String
parentId String?
type String
childIds String
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
page PageConfig @relation(fields: [pageId], references: [id], onDelete: Cascade)
parent ComponentNode? @relation("ParentChild", fields: [parentId], references: [id], onDelete: Cascade)
children ComponentNode[] @relation("ParentChild")
id String @id
type String
parentId String?
childIds String // JSON: string[]
order Int
pageId String
@@index([pageId])
@@index([parentId])
@@index([pageId, order])
}
model Workflow {
id String @id @default(uuid())
name String @unique
description String?
trigger String
triggerConfig String
steps String
isActive Boolean @default(true)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creator User @relation(fields: [createdBy], references: [id])
@@index([trigger])
@@index([isActive])
}
model LuaScript {
id String @id @default(uuid())
name String @unique
description String?
code String
isSandboxed Boolean @default(true)
allowedGlobals String
timeoutMs Int @default(5000)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creator User @relation(fields: [createdBy], references: [id])
@@index([isSandboxed])
}
model InstalledPackage {
packageId String @id
model ComponentConfig {
id String @id
componentId String
props String // JSON
styles String // JSON
events String // JSON
conditionalRendering String?
@@index([componentId])
}
model Workflow {
id String @id
tenantId String?
installedAt DateTime
name String
description String?
nodes String // JSON: WorkflowNode[]
edges String // JSON: WorkflowEdge[]
enabled Boolean
version Int @default(1)
createdAt BigInt?
updatedAt BigInt?
createdBy String?
@@index([tenantId])
@@index([enabled])
}
model LuaScript {
id String @id
tenantId String?
name String
description String?
code String
parameters String // JSON: Array<{name, type}>
returnType String?
isSandboxed Boolean @default(true)
allowedGlobals String @default("[]")
timeoutMs Int @default(5000)
version Int @default(1)
createdAt BigInt?
updatedAt BigInt?
createdBy String?
@@index([tenantId])
@@index([name])
}
model InstalledPackage {
packageId String @id
tenantId String?
installedAt BigInt
version String
enabled Boolean
config String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
model PackageData {
packageId String @id
data String // JSON
}

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env node
/* eslint-disable no-console */
const fs = require('fs')
const path = require('path')
const header = `datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}`
const models = [
{
name: 'User',
fields: [
{ name: 'id', type: 'String', attributes: ['@id'] },
{ name: 'username', type: 'String', attributes: ['@unique'] },
{ name: 'email', type: 'String', attributes: ['@unique'] },
{ name: 'role', type: 'String' },
{ name: 'profilePicture', type: 'String?' },
{ name: 'bio', type: 'String?' },
{ name: 'createdAt', type: 'BigInt' },
{ name: 'tenantId', type: 'String?' },
{ name: 'isInstanceOwner', type: 'Boolean', attributes: ['@default(false)'] },
{ name: 'passwordChangeTimestamp', type: 'BigInt?' },
{ name: 'firstLogin', type: 'Boolean', attributes: ['@default(false)'] },
],
blockAttributes: ['@@index([tenantId])', '@@index([role])'],
},
{
name: 'Credential',
fields: [
{ name: 'username', type: 'String', attributes: ['@id'] },
{ name: 'passwordHash', type: 'String' },
],
},
{
name: 'Session',
fields: [
{ name: 'id', type: 'String', attributes: ['@id'] },
{ name: 'userId', type: 'String' },
{ name: 'token', type: 'String', attributes: ['@unique'] },
{ name: 'expiresAt', type: 'BigInt' },
{ name: 'createdAt', type: 'BigInt' },
{ name: 'lastActivity', type: 'BigInt' },
{ name: 'ipAddress', type: 'String?' },
{ name: 'userAgent', type: 'String?' },
],
blockAttributes: ['@@index([userId])', '@@index([expiresAt])', '@@index([token])'],
},
{
name: 'PageConfig',
fields: [
{ name: 'id', type: 'String', attributes: ['@id'] },
{ name: 'tenantId', type: 'String?' },
{ name: 'packageId', type: 'String?' },
{ name: 'path', type: 'String', comment: '// Route pattern: /media/jobs, /forum/:id' },
{ name: 'title', type: 'String' },
{ name: 'description', type: 'String?' },
{ name: 'icon', type: 'String?' },
{ name: 'component', type: 'String?' },
{ name: 'componentTree', type: 'String', comment: '// JSON: full component tree' },
{ name: 'level', type: 'Int' },
{ name: 'requiresAuth', type: 'Boolean' },
{ name: 'requiredRole', type: 'String?' },
{ name: 'parentPath', type: 'String?' },
{ name: 'sortOrder', type: 'Int', attributes: ['@default(0)'] },
{ name: 'isPublished', type: 'Boolean', attributes: ['@default(true)'] },
{ name: 'params', type: 'String?' },
{ name: 'meta', type: 'String?' },
{ name: 'createdAt', type: 'BigInt?' },
{ name: 'updatedAt', type: 'BigInt?' },
],
blockAttributes: [
'@@unique([tenantId, path])',
'@@index([tenantId])',
'@@index([packageId])',
'@@index([level])',
'@@index([parentPath])',
],
},
{
name: 'ComponentNode',
fields: [
{ name: 'id', type: 'String', attributes: ['@id'] },
{ name: 'type', type: 'String' },
{ name: 'parentId', type: 'String?' },
{ name: 'childIds', type: 'String', comment: '// JSON: string[]' },
{ name: 'order', type: 'Int' },
{ name: 'pageId', type: 'String' },
],
blockAttributes: ['@@index([pageId])', '@@index([parentId])'],
},
{
name: 'ComponentConfig',
fields: [
{ name: 'id', type: 'String', attributes: ['@id'] },
{ name: 'componentId', type: 'String' },
{ name: 'props', type: 'String', comment: '// JSON' },
{ name: 'styles', type: 'String', comment: '// JSON' },
{ name: 'events', type: 'String', comment: '// JSON' },
{ name: 'conditionalRendering', type: 'String?' },
],
blockAttributes: ['@@index([componentId])'],
},
{
name: 'Workflow',
fields: [
{ name: 'id', type: 'String', attributes: ['@id'] },
{ name: 'tenantId', type: 'String?' },
{ name: 'name', type: 'String' },
{ name: 'description', type: 'String?' },
{ name: 'nodes', type: 'String', comment: '// JSON: WorkflowNode[]' },
{ name: 'edges', type: 'String', comment: '// JSON: WorkflowEdge[]' },
{ name: 'enabled', type: 'Boolean' },
{ name: 'version', type: 'Int', attributes: ['@default(1)'] },
{ name: 'createdAt', type: 'BigInt?' },
{ name: 'updatedAt', type: 'BigInt?' },
{ name: 'createdBy', type: 'String?' },
],
blockAttributes: ['@@index([tenantId])', '@@index([enabled])'],
},
{
name: 'LuaScript',
fields: [
{ name: 'id', type: 'String', attributes: ['@id'] },
{ name: 'tenantId', type: 'String?' },
{ name: 'name', type: 'String' },
{ name: 'description', type: 'String?' },
{ name: 'code', type: 'String' },
{ name: 'parameters', type: 'String', comment: '// JSON: Array<{name, type}>' },
{ name: 'returnType', type: 'String?' },
{ name: 'isSandboxed', type: 'Boolean', attributes: ['@default(true)'] },
{ name: 'allowedGlobals', type: 'String', attributes: ['@default("[]")'] },
{ name: 'timeoutMs', type: 'Int', attributes: ['@default(5000)'] },
{ name: 'version', type: 'Int', attributes: ['@default(1)'] },
{ name: 'createdAt', type: 'BigInt?' },
{ name: 'updatedAt', type: 'BigInt?' },
{ name: 'createdBy', type: 'String?' },
],
blockAttributes: ['@@index([tenantId])', '@@index([name])'],
},
{
name: 'InstalledPackage',
fields: [
{ name: 'packageId', type: 'String', attributes: ['@id'] },
{ name: 'tenantId', type: 'String?' },
{ name: 'installedAt', type: 'BigInt' },
{ name: 'version', type: 'String' },
{ name: 'enabled', type: 'Boolean' },
{ name: 'config', type: 'String?' },
],
blockAttributes: ['@@index([tenantId])'],
},
{
name: 'PackageData',
fields: [
{ name: 'packageId', type: 'String', attributes: ['@id'] },
{ name: 'data', type: 'String', comment: '// JSON' },
],
},
]
const renderField = (field) => {
const attrs = field.attributes ? ` ${field.attributes.join(' ')}` : ''
const comment = field.comment ? ` ${field.comment}` : ''
return ` ${field.name} ${field.type}${attrs}${comment}`
}
const renderModel = (model) => {
const lines = [`model ${model.name} {`]
model.fields.forEach((field) => {
lines.push(renderField(field))
})
if (model.blockAttributes) {
model.blockAttributes.forEach((attr) => {
lines.push(` ${attr}`)
})
}
lines.push('}')
return lines.join('\n')
}
const schema = [header, models.map(renderModel).join('\n\n')].join('\n\n')
const outputPath = path.resolve(__dirname, '../../backends/prisma/schema.prisma')
fs.writeFileSync(outputPath, schema + '\n', 'utf8')
console.log(`Prisma schema written to ${outputPath}`)