From 445f4f4028cee4ef513606312fca688260ad6ab8 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Wed, 7 Jan 2026 13:43:37 +0000 Subject: [PATCH] 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. --- dbal/development/src/adapters/memory/index.ts | 246 ++++++++++++++++++ .../src/core/client/adapter-factory.ts | 4 + dbal/development/src/core/client/mappers.ts | 2 +- .../session/lifecycle/extend-session.ts | 4 +- dbal/development/src/runtime/config.ts | 4 +- .../tests/core/entities/lua-script.test.ts | 6 +- .../tests/core/entities/session.test.ts | 6 +- dbal/production/include/dbal/core/types.hpp | 71 ++--- .../src/adapters/sql/sql_adapter.hpp | 8 +- .../src/adapters/sqlite/sqlite_adapter.cpp | 13 +- dbal/production/src/client.cpp | 8 +- .../package/batch/batch_create_packages.hpp | 4 +- .../entities/package/batch/batch_packages.hpp | 4 +- .../entities/package/crud/delete_package.hpp | 2 +- .../entities/package/crud/list_packages.hpp | 68 ++--- .../entities/package/crud/update_package.hpp | 86 ++---- .../src/entities/page/crud/get/get_page.hpp | 2 +- .../workflow/crud/create_workflow.hpp | 38 ++- .../entities/workflow/crud/list_workflows.hpp | 39 +-- .../workflow/crud/update_workflow.hpp | 68 ++--- .../validation/entity/workflow_validation.hpp | 8 - dbal/production/tests/unit/client_test.cpp | 243 +++++++++-------- dbal/shared/backends/prisma/schema.prisma | 226 ++++++++-------- .../shared/tools/codegen/gen_prisma_schema.js | 190 ++++++++++++++ 24 files changed, 890 insertions(+), 460 deletions(-) create mode 100644 dbal/development/src/adapters/memory/index.ts create mode 100644 dbal/shared/tools/codegen/gen_prisma_schema.js diff --git a/dbal/development/src/adapters/memory/index.ts b/dbal/development/src/adapters/memory/index.ts new file mode 100644 index 000000000..3595f87ed --- /dev/null +++ b/dbal/development/src/adapters/memory/index.ts @@ -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 = { + Credential: 'username', + InstalledPackage: 'packageId', + PackageData: 'packageId', +} + +const resolveIdField = (entity: string, data?: Record): 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 => { + 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[], + filter?: Record, +): Record[] => { + 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[], + sort?: Record, +): Record[] => { + 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>> = new Map() + + private getEntityStore(entity: string): Map> { + const existing = this.store.get(entity) + if (existing) return existing + const created = new Map>() + this.store.set(entity, created) + return created + } + + async create(entity: string, data: Record): Promise { + 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 { + const entityStore = this.getEntityStore(entity) + return entityStore.get(id) ?? null + } + + async update(entity: string, id: string, data: Record): Promise { + 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 { + const entityStore = this.getEntityStore(entity) + return entityStore.delete(id) + } + + async list(entity: string, options?: ListOptions): Promise> { + 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): Promise { + 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 { + return this.findFirst(entity, { [field]: value }) + } + + async upsert( + entity: string, + uniqueField: string, + uniqueValue: unknown, + createData: Record, + updateData: Record, + ): Promise { + 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, + ): Promise { + 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 { + 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): Promise { + 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[]): Promise { + 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, + data: Record, + ): Promise { + 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 { + return Promise.resolve({ + transactions: false, + joins: false, + fullTextSearch: false, + ttl: false, + jsonQueries: false, + aggregations: false, + relations: false, + }) + } + + async close(): Promise { + this.store.clear() + } +} diff --git a/dbal/development/src/core/client/adapter-factory.ts b/dbal/development/src/core/client/adapter-factory.ts index 74a6d629e..73a6e1bae 100644 --- a/dbal/development/src/core/client/adapter-factory.ts +++ b/dbal/development/src/core/client/adapter-factory.ts @@ -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, diff --git a/dbal/development/src/core/client/mappers.ts b/dbal/development/src/core/client/mappers.ts index b9abc9661..de9b50d73 100644 --- a/dbal/development/src/core/client/mappers.ts +++ b/dbal/development/src/core/client/mappers.ts @@ -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', []) } diff --git a/dbal/development/src/core/entities/session/lifecycle/extend-session.ts b/dbal/development/src/core/entities/session/lifecycle/extend-session.ts index 078bb6679..40a33adbb 100644 --- a/dbal/development/src/core/entities/session/lifecycle/extend-session.ts +++ b/dbal/development/src/core/entities/session/lifecycle/extend-session.ts @@ -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) diff --git a/dbal/development/src/runtime/config.ts b/dbal/development/src/runtime/config.ts index 32a309541..b3d8b7b40 100644 --- a/dbal/development/src/runtime/config.ts +++ b/dbal/development/src/runtime/config.ts @@ -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 { diff --git a/dbal/development/tests/core/entities/lua-script.test.ts b/dbal/development/tests/core/entities/lua-script.test.ts index 7c2d3913f..6533d3bc8 100644 --- a/dbal/development/tests/core/entities/lua-script.test.ts +++ b/dbal/development/tests/core/entities/lua-script.test.ts @@ -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) diff --git a/dbal/development/tests/core/entities/session.test.ts b/dbal/development/tests/core/entities/session.test.ts index 698f6baa9..4b65ab0bd 100644 --- a/dbal/development/tests/core/entities/session.test.ts +++ b/dbal/development/tests/core/entities/session.test.ts @@ -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) diff --git a/dbal/production/include/dbal/core/types.hpp b/dbal/production/include/dbal/core/types.hpp index a77f3abbb..e70ae34be 100644 --- a/dbal/production/include/dbal/core/types.hpp +++ b/dbal/production/include/dbal/core/types.hpp @@ -164,38 +164,45 @@ struct MoveComponentInput { int order = 0; }; -struct Workflow { - std::string id; - std::string name; - std::optional 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 description; - std::string trigger; - Json trigger_config; - Json steps; - bool is_active = true; - std::string created_by; -}; - -struct UpdateWorkflowInput { - std::optional name; - std::optional description; - std::optional trigger; - std::optional trigger_config; - std::optional steps; - std::optional is_active; - std::optional created_by; -}; +struct Workflow { + std::string id; + std::optional tenant_id; + std::string name; + std::optional description; + std::string nodes; + std::string edges; + bool enabled; + int version = 1; + std::optional created_at; + std::optional updated_at; + std::optional created_by; +}; + +struct CreateWorkflowInput { + std::optional tenant_id; + std::string name; + std::optional description; + std::string nodes; + std::string edges; + bool enabled; + int version = 1; + std::optional created_at; + std::optional updated_at; + std::optional created_by; +}; + +struct UpdateWorkflowInput { + std::optional tenant_id; + std::optional name; + std::optional description; + std::optional nodes; + std::optional edges; + std::optional enabled; + std::optional version; + std::optional created_at; + std::optional updated_at; + std::optional created_by; +}; struct Session { std::string id; diff --git a/dbal/production/src/adapters/sql/sql_adapter.hpp b/dbal/production/src/adapters/sql/sql_adapter.hpp index 1fd9df7a6..f109ddcf5 100644 --- a/dbal/production/src/adapters/sql/sql_adapter.hpp +++ b/dbal/production/src/adapters/sql/sql_adapter.hpp @@ -319,17 +319,17 @@ public: return Error::notImplemented("SQL adapter listLuaScripts"); } - Result createPackage(const CreatePackageInput& input) override { + Result createPackage(const CreatePackageInput& input) override { (void)input; return Error::notImplemented("SQL adapter createPackage"); } - Result getPackage(const std::string& id) override { + Result getPackage(const std::string& id) override { (void)id; return Error::notImplemented("SQL adapter getPackage"); } - Result updatePackage(const std::string& id, const UpdatePackageInput& input) override { + Result 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> listPackages(const ListOptions& options) override { + Result> listPackages(const ListOptions& options) override { (void)options; return Error::notImplemented("SQL adapter listPackages"); } diff --git a/dbal/production/src/adapters/sqlite/sqlite_adapter.cpp b/dbal/production/src/adapters/sqlite/sqlite_adapter.cpp index e9a42e6fe..b2ead752f 100644 --- a/dbal/production/src/adapters/sqlite/sqlite_adapter.cpp +++ b/dbal/production/src/adapters/sqlite/sqlite_adapter.cpp @@ -90,15 +90,16 @@ public: Result 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); } diff --git a/dbal/production/src/client.cpp b/dbal/production/src/client.cpp index e4f22c710..69a2c8f42 100644 --- a/dbal/production/src/client.cpp +++ b/dbal/production/src/client.cpp @@ -225,15 +225,15 @@ Result> Client::searchLuaScripts(const std::string& query return entities::lua_script::search(getStore(), query, created_by, limit); } -Result Client::createPackage(const CreatePackageInput& input) { +Result Client::createPackage(const CreatePackageInput& input) { return entities::package::create(getStore(), input); } -Result Client::getPackage(const std::string& id) { +Result Client::getPackage(const std::string& id) { return entities::package::get(getStore(), id); } -Result Client::updatePackage(const std::string& id, const UpdatePackageInput& input) { +Result Client::updatePackage(const std::string& id, const UpdatePackageInput& input) { return entities::package::update(getStore(), id, input); } @@ -241,7 +241,7 @@ Result Client::deletePackage(const std::string& id) { return entities::package::remove(getStore(), id); } -Result> Client::listPackages(const ListOptions& options) { +Result> Client::listPackages(const ListOptions& options) { return entities::package::list(getStore(), options); } diff --git a/dbal/production/src/entities/package/batch/batch_create_packages.hpp b/dbal/production/src/entities/package/batch/batch_create_packages.hpp index 9e5a76179..5419da93a 100644 --- a/dbal/production/src/entities/package/batch/batch_create_packages.hpp +++ b/dbal/production/src/entities/package/batch/batch_create_packages.hpp @@ -30,13 +30,13 @@ inline Result batchCreatePackages(InMemoryStore& store, const std::vectorsecond.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(static_cast(created_ids.size())); diff --git a/dbal/production/src/entities/package/batch/batch_packages.hpp b/dbal/production/src/entities/package/batch/batch_packages.hpp index ebbbad71f..b2914c39b 100644 --- a/dbal/production/src/entities/package/batch/batch_packages.hpp +++ b/dbal/production/src/entities/package/batch/batch_packages.hpp @@ -32,13 +32,13 @@ inline Result batchCreate(InMemoryStore& store, const std::vectorsecond.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(static_cast(created_ids.size())); diff --git a/dbal/production/src/entities/package/crud/delete_package.hpp b/dbal/production/src/entities/package/crud/delete_package.hpp index 276c571a4..4e50f1dac 100644 --- a/dbal/production/src/entities/package/crud/delete_package.hpp +++ b/dbal/production/src/entities/package/crud/delete_package.hpp @@ -27,7 +27,7 @@ inline Result 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(true); diff --git a/dbal/production/src/entities/package/crud/list_packages.hpp b/dbal/production/src/entities/package/crud/list_packages.hpp index 154d8340a..0260439ad 100644 --- a/dbal/production/src/entities/package/crud/list_packages.hpp +++ b/dbal/production/src/entities/package/crud/list_packages.hpp @@ -17,53 +17,55 @@ namespace package { /** * List packages with filtering and pagination */ -inline Result> list(InMemoryStore& store, const ListOptions& options) { - std::vector packages; +inline Result> list(InMemoryStore& store, const ListOptions& options) { + std::vector 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(packages.size())); - if (start < static_cast(packages.size())) { - return Result>(std::vector(packages.begin() + start, packages.begin() + end)); - } - - return Result>(std::vector()); -} + if (start < static_cast(packages.size())) { + return Result>(std::vector(packages.begin() + start, packages.begin() + end)); + } + + return Result>(std::vector()); +} } // namespace package } // namespace entities diff --git a/dbal/production/src/entities/package/crud/update_package.hpp b/dbal/production/src/entities/package/crud/update_package.hpp index 6e09c3194..784a9a158 100644 --- a/dbal/production/src/entities/package/crud/update_package.hpp +++ b/dbal/production/src/entities/package/crud/update_package.hpp @@ -17,7 +17,7 @@ namespace package { /** * Update an existing package */ -inline Result update(InMemoryStore& store, const std::string& id, const UpdatePackageInput& input) { +inline Result 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 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); -} + return Result(package); +} } // namespace package } // namespace entities diff --git a/dbal/production/src/entities/page/crud/get/get_page.hpp b/dbal/production/src/entities/page/crud/get/get_page.hpp index 817f86714..6b49a72b6 100644 --- a/dbal/production/src/entities/page/crud/get/get_page.hpp +++ b/dbal/production/src/entities/page/crud/get/get_page.hpp @@ -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 diff --git a/dbal/production/src/entities/workflow/crud/create_workflow.hpp b/dbal/production/src/entities/workflow/crud/create_workflow.hpp index a2cc5eb62..471894024 100644 --- a/dbal/production/src/entities/workflow/crud/create_workflow.hpp +++ b/dbal/production/src/entities/workflow/crud/create_workflow.hpp @@ -21,28 +21,22 @@ inline Result 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; diff --git a/dbal/production/src/entities/workflow/crud/list_workflows.hpp b/dbal/production/src/entities/workflow/crud/list_workflows.hpp index 7ef523608..981a51d35 100644 --- a/dbal/production/src/entities/workflow/crud/list_workflows.hpp +++ b/dbal/production/src/entities/workflow/crud/list_workflows.hpp @@ -23,18 +23,22 @@ inline Result> 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> 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(workflows.size())); diff --git a/dbal/production/src/entities/workflow/crud/update_workflow.hpp b/dbal/production/src/entities/workflow/crud/update_workflow.hpp index baa08b9f8..3b839dca0 100644 --- a/dbal/production/src/entities/workflow/crud/update_workflow.hpp +++ b/dbal/production/src/entities/workflow/crud/update_workflow.hpp @@ -43,37 +43,43 @@ inline Result 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); } diff --git a/dbal/production/src/validation/entity/workflow_validation.hpp b/dbal/production/src/validation/entity/workflow_validation.hpp index e45205fde..398ec96c1 100644 --- a/dbal/production/src/validation/entity/workflow_validation.hpp +++ b/dbal/production/src/validation/entity/workflow_validation.hpp @@ -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 allowed = {"manual", "schedule", "event", "webhook"}; - return std::find(allowed.begin(), allowed.end(), trigger) != allowed.end(); -} - } // namespace validation } // namespace dbal diff --git a/dbal/production/tests/unit/client_test.cpp b/dbal/production/tests/unit/client_test.cpp index f13e73730..e7d62b1fd 100644 --- a/dbal/production/tests/unit/client_test.cpp +++ b/dbal/production/tests/unit/client_test.cpp @@ -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 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 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 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()); diff --git a/dbal/shared/backends/prisma/schema.prisma b/dbal/shared/backends/prisma/schema.prisma index 2a843f26b..a2c9b24b8 100644 --- a/dbal/shared/backends/prisma/schema.prisma +++ b/dbal/shared/backends/prisma/schema.prisma @@ -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 } diff --git a/dbal/shared/tools/codegen/gen_prisma_schema.js b/dbal/shared/tools/codegen/gen_prisma_schema.js new file mode 100644 index 000000000..ed78b0ab9 --- /dev/null +++ b/dbal/shared/tools/codegen/gen_prisma_schema.js @@ -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}`)