Compare commits

..

95 Commits

Author SHA1 Message Date
4ab7aac63e Merge branch 'main' into codex/add-user-management-components 2025-12-27 18:48:59 +00:00
1f7c2e637e Merge pull request #257 from johndoe6345789/codex/create-fields-and-actions-components
Refactor component dialog fields and hierarchy tree
2025-12-27 18:48:35 +00:00
9c354fdac5 Merge branch 'main' into codex/create-fields-and-actions-components 2025-12-27 18:48:26 +00:00
f57b41f86d refactor: extract dialog fields and hierarchy tree 2025-12-27 18:48:15 +00:00
1e9a6271ea feat: add user management subcomponents 2025-12-27 18:47:43 +00:00
7989c700b9 Merge pull request #254 from johndoe6345789/codex/create-shared-powertransfer-tabs-component
Refactor power transfer tab layout
2025-12-27 18:47:21 +00:00
02e7188b20 Merge branch 'main' into codex/create-shared-powertransfer-tabs-component 2025-12-27 18:47:13 +00:00
1523cf735c refactor: extract power transfer sections 2025-12-27 18:47:02 +00:00
adedf5f70c Merge pull request #253 from johndoe6345789/codex/create-level4/tabs/config.ts-and-tabcontent.tsx
refactor: modularize level4 tabs
2025-12-27 18:46:26 +00:00
c069bd0540 Merge branch 'main' into codex/create-level4/tabs/config.ts-and-tabcontent.tsx 2025-12-27 18:46:18 +00:00
871b84ebf4 refactor: modularize level4 tabs 2025-12-27 18:46:06 +00:00
db8c01de1b Merge pull request #251 from johndoe6345789/codex/create-section-components-for-levels
Refactor level pages to share section components
2025-12-27 18:45:35 +00:00
85afb870e8 Merge branch 'main' into codex/create-section-components-for-levels 2025-12-27 18:45:26 +00:00
57a6bd32d6 refactor: share level section components 2025-12-27 18:45:14 +00:00
afacdb82cc Merge pull request #250 from johndoe6345789/codex/add-contact-form-example-components
Add contact form example config and preview
2025-12-27 18:44:55 +00:00
b9350f0da9 Merge branch 'main' into codex/add-contact-form-example-components 2025-12-27 18:44:49 +00:00
4f2bff3a47 feat: add contact form example config and preview 2025-12-27 18:44:34 +00:00
de605d4809 Merge pull request #248 from johndoe6345789/codex/create-schema-level-4-files
Refactor Level 4 schema editor into modular components
2025-12-27 18:44:14 +00:00
67c7509bb9 Merge branch 'main' into codex/create-schema-level-4-files 2025-12-27 18:44:05 +00:00
ecd04fa1a0 refactor: modularize level 4 schema editor 2025-12-27 18:43:54 +00:00
f00d345fe8 Merge pull request #247 from johndoe6345789/codex/add-selectors,-actions,-and-storage-files
Refactor Lua blocks state hook utilities
2025-12-27 18:43:37 +00:00
d161f0f9cd Merge branch 'main' into codex/add-selectors,-actions,-and-storage-files 2025-12-27 18:43:29 +00:00
a72299176c refactor: modularize lua blocks state hook 2025-12-27 18:43:12 +00:00
a26666199c Merge pull request #245 from johndoe6345789/codex/create-blockitem-and-grouping-files
refactor: extract lua block item and grouping helpers
2025-12-27 18:42:52 +00:00
7932581ec3 Merge branch 'main' into codex/create-blockitem-and-grouping-files 2025-12-27 18:42:46 +00:00
4d8394acc0 refactor: extract lua block item and grouping helpers 2025-12-27 18:42:14 +00:00
704c1bca86 Merge pull request #244 from johndoe6345789/codex/add-luasnippetlibrary-components
Refactor Lua snippet library into modular components
2025-12-27 18:41:52 +00:00
ee76be73f2 Merge branch 'main' into codex/add-luasnippetlibrary-components 2025-12-27 18:41:47 +00:00
e0c556c279 refactor: modularize lua snippet library 2025-12-27 18:41:21 +00:00
73a53c4715 Merge pull request #242 from johndoe6345789/codex/create-blocklistview,-codepreview,-and-useluablockeditorstat
Refactor Lua blocks editor composition
2025-12-27 18:41:01 +00:00
6d4b786150 Merge branch 'main' into codex/create-blocklistview,-codepreview,-and-useluablockeditorstat 2025-12-27 18:40:53 +00:00
7c061b43ca refactor: modularize lua blocks editor 2025-12-27 18:40:43 +00:00
adcd9c69de Merge pull request #240 from johndoe6345789/codex/create-header-and-sidebar-components
Refactor Codegen Studio layout
2025-12-27 18:40:23 +00:00
4bd98918cc Merge branch 'main' into codex/create-header-and-sidebar-components 2025-12-27 18:40:18 +00:00
97d461b667 refactor: modularize codegen studio layout 2025-12-27 18:40:06 +00:00
d322e425cb Merge pull request #239 from johndoe6345789/codex/add-package-operations-for-publish,-unpublish,-validate
Add package publish lifecycle helpers
2025-12-27 18:39:46 +00:00
7ae32965cf Merge branch 'main' into codex/add-package-operations-for-publish,-unpublish,-validate 2025-12-27 18:39:37 +00:00
c0f1b5af14 feat: add package lifecycle operations 2025-12-27 18:39:27 +00:00
a7fde7cd0d Merge pull request #237 from johndoe6345789/codex/create-user-operations-in-core/entities
Refactor user operations into separate modules
2025-12-27 18:39:07 +00:00
cea8211297 Merge branch 'main' into codex/create-user-operations-in-core/entities 2025-12-27 18:38:59 +00:00
66f9d2cfe6 refactor: split user operations into separate modules 2025-12-27 18:38:50 +00:00
366ffb5de9 Merge pull request #235 from johndoe6345789/codex/add-websocket-bridge-lifecycle-and-routing
Refactor websocket bridge lifecycle and routing
2025-12-27 18:38:23 +00:00
e848a7bac5 Merge branch 'main' into codex/add-websocket-bridge-lifecycle-and-routing 2025-12-27 18:38:13 +00:00
b10bef82a9 refactor: harden websocket bridge lifecycle 2025-12-27 18:38:04 +00:00
1e3dff83fa Merge pull request #221 from johndoe6345789/codex/create-tenant-context-and-audit-hooks
Refactor tenant-aware blob storage context and hooks
2025-12-27 18:37:46 +00:00
901a5438dd Merge branch 'main' into codex/create-tenant-context-and-audit-hooks 2025-12-27 18:37:39 +00:00
d84c55cfe1 Merge pull request #232 from johndoe6345789/codex/introduce-shared-helpers-and-refactor-storage
Refactor memory storage helpers into utilities and serialization
2025-12-27 18:37:13 +00:00
9331a1b7f7 Merge branch 'main' into codex/introduce-shared-helpers-and-refactor-storage 2025-12-27 18:37:04 +00:00
bcac86fce9 refactor: modularize memory storage helpers 2025-12-27 18:36:56 +00:00
824a1f4487 Merge pull request #230 from johndoe6345789/codex/refactor-acl-adapter-structure-and-imports
Refactor ACL adapter into strategies
2025-12-27 18:36:35 +00:00
af4a2246c0 Merge branch 'main' into codex/refactor-acl-adapter-structure-and-imports 2025-12-27 18:36:27 +00:00
fcd0e55125 refactor: modularize ACL adapter strategies 2025-12-27 18:36:16 +00:00
4b3d5f4043 Merge pull request #228 from johndoe6345789/codex/create-c++-build-assistant-files
Refactor cpp build assistant CLI into modular components
2025-12-27 18:35:56 +00:00
a47085dc67 Merge branch 'main' into codex/create-c++-build-assistant-files 2025-12-27 18:35:48 +00:00
756c48fc83 refactor: modularize cpp build assistant 2025-12-27 18:35:35 +00:00
ac45fb171c Merge pull request #226 from johndoe6345789/codex/add-moderatorpanel-components
Refactor moderator panel into modular components
2025-12-27 18:35:08 +00:00
7562c4184d Merge branch 'main' into codex/add-moderatorpanel-components 2025-12-27 18:34:58 +00:00
fcd7322861 refactor: modularize moderator panel components 2025-12-27 18:34:46 +00:00
7a64fa6b7e Merge pull request #224 from johndoe6345789/codex/add-dropdownconfigform-and-previewpane
Refactor dropdown config manager into modular components
2025-12-27 18:34:31 +00:00
9d3a39f6cc Merge branch 'main' into codex/add-dropdownconfigform-and-previewpane 2025-12-27 18:34:23 +00:00
d9a8e75fbf refactor: extract dropdown manager components 2025-12-27 18:34:09 +00:00
5cb1e9f63e Merge pull request #223 from johndoe6345789/codex/create-routestable,-routeeditor,-and-preview-components
Refactor page routes manager into modular components
2025-12-27 18:33:50 +00:00
53d365f07d Merge branch 'main' into codex/create-routestable,-routeeditor,-and-preview-components 2025-12-27 18:33:34 +00:00
a320a85353 refactor: split page routes manager components 2025-12-27 18:33:06 +00:00
01ae4c753f refactor: modularize tenant-aware blob storage 2025-12-27 18:32:40 +00:00
c04d8923b3 Merge pull request #219 from johndoe6345789/codex/create-types-directory-and-files
Add foundation type modules
2025-12-27 18:32:21 +00:00
658bd1e196 Merge branch 'main' into codex/create-types-directory-and-files 2025-12-27 18:32:12 +00:00
149ee90339 chore: add foundation type modules 2025-12-27 18:32:01 +00:00
eea561c225 Merge pull request #217 from johndoe6345789/codex/create-toolbar-and-schemasection-components
Extract JSON editor UI components
2025-12-27 18:31:45 +00:00
ead2acee40 Merge branch 'main' into codex/create-toolbar-and-schemasection-components 2025-12-27 18:31:40 +00:00
07efe7609a refactor: extract json editor ui components 2025-12-27 18:31:22 +00:00
daefe075b3 Merge pull request #216 from johndoe6345789/codex/add-paletteeditor-and-previewpane-components
Modularize theme editor components
2025-12-27 18:31:08 +00:00
b6b48eafb3 feat: modularize theme editor 2025-12-27 18:30:57 +00:00
cadaa8c5fe Merge pull request #211 from johndoe6345789/codex/refactor-error-as-todo-refactor.ts
Refactor error-as-todo runner into modular components
2025-12-27 18:24:06 +00:00
f4a5950c31 Merge branch 'main' into codex/refactor-error-as-todo-refactor.ts 2025-12-27 18:23:48 +00:00
d44385fc41 refactor: modularize error-as-todo runner 2025-12-27 18:23:08 +00:00
25220fad97 Merge pull request #207 from johndoe6345789/codex/refactor-dbal-directory-into-lambda-modules
Refactor DBAL helpers into modular lambda subfolders
2025-12-27 18:21:44 +00:00
b9ac291e68 Merge branch 'main' into codex/refactor-dbal-directory-into-lambda-modules 2025-12-27 18:21:37 +00:00
880544e58d refactor: modularize dbal utilities 2025-12-27 18:21:26 +00:00
579103e916 Merge pull request #206 from johndoe6345789/codex/refactor-tools-scripts-into-lambda-files
refactor: modularize stub tooling
2025-12-27 18:21:03 +00:00
0abb48c7aa Merge branch 'main' into codex/refactor-tools-scripts-into-lambda-files 2025-12-27 18:20:54 +00:00
6447e7a203 refactor: modularize stub tooling 2025-12-27 18:20:45 +00:00
b7a721cf8d Merge pull request #203 from johndoe6345789/codex/refactor-dbaldemo-into-separate-files
Refactor DBAL demo tabs into separate components
2025-12-27 18:19:56 +00:00
c0015f45fc Merge branch 'main' into codex/refactor-dbaldemo-into-separate-files 2025-12-27 18:19:51 +00:00
219637c4c6 refactor: split dbal demo tabs 2025-12-27 18:19:38 +00:00
1a6d1f5f2d Merge pull request #200 from johndoe6345789/codex/refactor-command.tsx-into-multiple-files
Refactor command dialog into modular components
2025-12-27 18:12:44 +00:00
f5baf35666 Merge branch 'main' into codex/refactor-command.tsx-into-multiple-files 2025-12-27 18:12:39 +00:00
30f35ae07f refactor: split command dialog components 2025-12-27 18:12:29 +00:00
06def0d890 Merge pull request #198 from johndoe6345789/codex/refactor-runlist-into-lambda-components
Refactor run list view into reusable subcomponents
2025-12-27 18:12:13 +00:00
43f8325ad2 Merge branch 'main' into codex/refactor-runlist-into-lambda-components 2025-12-27 18:12:04 +00:00
f273de2cab refactor: extract run list components 2025-12-27 18:11:52 +00:00
76f4d131ad Merge pull request #197 from johndoe6345789/codex/refactor-tool-scripts-into-smaller-lambdas
Modularize error-as-todo refactoring tool
2025-12-27 18:11:35 +00:00
1beeeba7ff Merge branch 'main' into codex/refactor-tool-scripts-into-smaller-lambdas 2025-12-27 18:11:26 +00:00
d12b24a36b refactor: modularize error-as-todo runner 2025-12-27 18:11:17 +00:00
3e0dbfd78d Merge pull request #183 from johndoe6345789/copilot/fix-issue-triage-script
Fix triage script to dynamically find duplicates via GitHub API
2025-12-27 18:09:14 +00:00
199 changed files with 10692 additions and 7496 deletions

View File

@@ -1,258 +1,3 @@
/**
* @file acl-adapter.ts
* @description ACL adapter that wraps a base adapter with access control
*/
import type { DBALAdapter, AdapterCapabilities } from './adapter'
import type { ListOptions, ListResult } from '../core/foundation/types'
import type { User, ACLRule } from './acl/types'
import { resolvePermissionOperation } from './acl/resolve-permission-operation'
import { checkPermission } from './acl/check-permission'
import { checkRowLevelAccess } from './acl/check-row-level-access'
import { logAudit } from './acl/audit-logger'
import { defaultACLRules } from './acl/default-rules'
export class ACLAdapter implements DBALAdapter {
private baseAdapter: DBALAdapter
private user: User
private rules: ACLRule[]
private auditLog: boolean
constructor(
baseAdapter: DBALAdapter,
user: User,
options?: {
rules?: ACLRule[]
auditLog?: boolean
}
) {
this.baseAdapter = baseAdapter
this.user = user
this.rules = options?.rules || defaultACLRules
this.auditLog = options?.auditLog ?? true
}
private log(entity: string, operation: string, success: boolean, message?: string): void {
if (this.auditLog) {
logAudit(entity, operation, success, this.user, message)
}
}
async create(entity: string, data: Record<string, unknown>): Promise<unknown> {
const operation = 'create'
checkPermission(entity, operation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.create(entity, data)
this.log(entity, operation, true)
return result
} catch (error) {
this.log(entity, operation, false, (error as Error).message)
throw error
}
}
async read(entity: string, id: string): Promise<unknown | null> {
const operation = 'read'
checkPermission(entity, operation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.read(entity, id)
if (result) {
checkRowLevelAccess(entity, operation, result as Record<string, unknown>, this.user, this.rules, this.log.bind(this))
}
this.log(entity, operation, true)
return result
} catch (error) {
this.log(entity, operation, false, (error as Error).message)
throw error
}
}
async update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
const operation = 'update'
checkPermission(entity, operation, this.user, this.rules, this.log.bind(this))
const existing = await this.baseAdapter.read(entity, id)
if (existing) {
checkRowLevelAccess(entity, operation, existing as Record<string, unknown>, this.user, this.rules, this.log.bind(this))
}
try {
const result = await this.baseAdapter.update(entity, id, data)
this.log(entity, operation, true)
return result
} catch (error) {
this.log(entity, operation, false, (error as Error).message)
throw error
}
}
async delete(entity: string, id: string): Promise<boolean> {
const operation = 'delete'
checkPermission(entity, operation, this.user, this.rules, this.log.bind(this))
const existing = await this.baseAdapter.read(entity, id)
if (existing) {
checkRowLevelAccess(entity, operation, existing as Record<string, unknown>, this.user, this.rules, this.log.bind(this))
}
try {
const result = await this.baseAdapter.delete(entity, id)
this.log(entity, operation, true)
return result
} catch (error) {
this.log(entity, operation, false, (error as Error).message)
throw error
}
}
async list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
const operation = 'list'
checkPermission(entity, operation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.list(entity, options)
this.log(entity, operation, true)
return result
} catch (error) {
this.log(entity, operation, false, (error as Error).message)
throw error
}
}
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
const resolvedOperation = resolvePermissionOperation('findFirst')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.findFirst(entity, filter)
if (result) {
checkRowLevelAccess(entity, resolvedOperation, result as Record<string, unknown>, this.user, this.rules, this.log.bind(this))
}
this.log(entity, 'findFirst', true)
return result
} catch (error) {
this.log(entity, 'findFirst', false, (error as Error).message)
throw error
}
}
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
const resolvedOperation = resolvePermissionOperation('findByField')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.findByField(entity, field, value)
if (result) {
checkRowLevelAccess(entity, resolvedOperation, result as Record<string, unknown>, this.user, this.rules, this.log.bind(this))
}
this.log(entity, 'findByField', true)
return result
} catch (error) {
this.log(entity, 'findByField', false, (error as Error).message)
throw error
}
}
async upsert(
entity: string,
filter: Record<string, unknown>,
createData: Record<string, unknown>,
updateData: Record<string, unknown>
): Promise<unknown> {
checkPermission(entity, 'create', this.user, this.rules, this.log.bind(this))
checkPermission(entity, 'update', this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.upsert(entity, filter, createData, updateData)
this.log(entity, 'upsert', true)
return result
} catch (error) {
this.log(entity, 'upsert', false, (error as Error).message)
throw error
}
}
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
const resolvedOperation = resolvePermissionOperation('updateByField')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.updateByField(entity, field, value, data)
this.log(entity, 'updateByField', true)
return result
} catch (error) {
this.log(entity, 'updateByField', false, (error as Error).message)
throw error
}
}
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
const resolvedOperation = resolvePermissionOperation('deleteByField')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.deleteByField(entity, field, value)
this.log(entity, 'deleteByField', true)
return result
} catch (error) {
this.log(entity, 'deleteByField', false, (error as Error).message)
throw error
}
}
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
const resolvedOperation = resolvePermissionOperation('createMany')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.createMany(entity, data)
this.log(entity, 'createMany', true)
return result
} catch (error) {
this.log(entity, 'createMany', false, (error as Error).message)
throw error
}
}
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
const resolvedOperation = resolvePermissionOperation('updateMany')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.updateMany(entity, filter, data)
this.log(entity, 'updateMany', true)
return result
} catch (error) {
this.log(entity, 'updateMany', false, (error as Error).message)
throw error
}
}
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
const resolvedOperation = resolvePermissionOperation('deleteMany')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.deleteMany(entity, filter)
this.log(entity, 'deleteMany', true)
return result
} catch (error) {
this.log(entity, 'deleteMany', false, (error as Error).message)
throw error
}
}
async getCapabilities(): Promise<AdapterCapabilities> {
return this.baseAdapter.getCapabilities()
}
async close(): Promise<void> {
await this.baseAdapter.close()
}
}
// Re-export types for convenience
export type { User, ACLRule } from './acl/types'
export { ACLAdapter } from './acl-adapter'
export type { ACLAdapterOptions, ACLContext, ACLRule, User } from './acl-adapter/types'
export { defaultACLRules } from './acl/default-rules'

View File

@@ -0,0 +1,86 @@
import type { AdapterCapabilities, DBALAdapter } from '../adapter'
import type { ListOptions, ListResult } from '../../core/foundation/types'
import { createContext } from './context'
import { createReadStrategy } from './read-strategy'
import { createWriteStrategy } from './write-strategy'
import type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types'
export class ACLAdapter implements DBALAdapter {
private readonly context: ACLContext
private readonly readStrategy: ReturnType<typeof createReadStrategy>
private readonly writeStrategy: ReturnType<typeof createWriteStrategy>
constructor(baseAdapter: DBALAdapter, user: User, options?: ACLAdapterOptions) {
this.context = createContext(baseAdapter, user, options)
this.readStrategy = createReadStrategy(this.context)
this.writeStrategy = createWriteStrategy(this.context)
}
async create(entity: string, data: Record<string, unknown>): Promise<unknown> {
return this.writeStrategy.create(entity, data)
}
async read(entity: string, id: string): Promise<unknown | null> {
return this.readStrategy.read(entity, id)
}
async update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
return this.writeStrategy.update(entity, id, data)
}
async delete(entity: string, id: string): Promise<boolean> {
return this.writeStrategy.delete(entity, id)
}
async list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
return this.readStrategy.list(entity, options)
}
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
return this.readStrategy.findFirst(entity, filter)
}
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
return this.readStrategy.findByField(entity, field, value)
}
async upsert(
entity: string,
filter: Record<string, unknown>,
createData: Record<string, unknown>,
updateData: Record<string, unknown>,
): Promise<unknown> {
return this.writeStrategy.upsert(entity, filter, createData, updateData)
}
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
return this.writeStrategy.updateByField(entity, field, value, data)
}
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
return this.writeStrategy.deleteByField(entity, field, value)
}
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
return this.writeStrategy.createMany(entity, data)
}
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
return this.writeStrategy.updateMany(entity, filter, data)
}
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
return this.writeStrategy.deleteMany(entity, filter)
}
async getCapabilities(): Promise<AdapterCapabilities> {
return this.context.baseAdapter.getCapabilities()
}
async close(): Promise<void> {
await this.context.baseAdapter.close()
}
}
export type { ACLAdapterOptions, ACLContext, ACLRule, User }
export { defaultACLRules } from '../acl/default-rules'

View File

@@ -0,0 +1,67 @@
import type { ACLContext } from './context'
import { enforceRowAccess, resolveOperation, withAudit } from './guards'
export const findFirst = (context: ACLContext) => async (entity: string, filter?: Record<string, unknown>) => {
const operation = resolveOperation('findFirst')
return withAudit(context, entity, operation, async () => {
const result = await context.baseAdapter.findFirst(entity, filter)
if (result) {
enforceRowAccess(context, entity, operation, result as Record<string, unknown>)
}
return result
})
}
export const findByField = (context: ACLContext) => async (entity: string, field: string, value: unknown) => {
const operation = resolveOperation('findByField')
return withAudit(context, entity, operation, async () => {
const result = await context.baseAdapter.findByField(entity, field, value)
if (result) {
enforceRowAccess(context, entity, operation, result as Record<string, unknown>)
}
return result
})
}
export const upsert = (context: ACLContext) => async (
entity: string,
filter: Record<string, unknown>,
createData: Record<string, unknown>,
updateData: Record<string, unknown>,
) => {
return withAudit(context, entity, 'upsert', () => context.baseAdapter.upsert(entity, filter, createData, updateData))
}
export const updateByField = (context: ACLContext) => async (
entity: string,
field: string,
value: unknown,
data: Record<string, unknown>,
) => {
const operation = resolveOperation('updateByField')
return withAudit(context, entity, operation, () => context.baseAdapter.updateByField(entity, field, value, data))
}
export const deleteByField = (context: ACLContext) => async (entity: string, field: string, value: unknown) => {
const operation = resolveOperation('deleteByField')
return withAudit(context, entity, operation, () => context.baseAdapter.deleteByField(entity, field, value))
}
export const createMany = (context: ACLContext) => async (entity: string, data: Record<string, unknown>[]) => {
const operation = resolveOperation('createMany')
return withAudit(context, entity, operation, () => context.baseAdapter.createMany(entity, data))
}
export const updateMany = (context: ACLContext) => async (
entity: string,
filter: Record<string, unknown>,
data: Record<string, unknown>,
) => {
const operation = resolveOperation('updateMany')
return withAudit(context, entity, operation, () => context.baseAdapter.updateMany(entity, filter, data))
}
export const deleteMany = (context: ACLContext) => async (entity: string, filter?: Record<string, unknown>) => {
const operation = resolveOperation('deleteMany')
return withAudit(context, entity, operation, () => context.baseAdapter.deleteMany(entity, filter))
}

View File

@@ -0,0 +1,26 @@
import type { DBALAdapter } from '../adapter'
import type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types'
import { logAudit } from '../acl/audit-logger'
import { defaultACLRules } from '../acl/default-rules'
export const createContext = (
baseAdapter: DBALAdapter,
user: User,
options?: ACLAdapterOptions,
): ACLContext => {
const auditLog = options?.auditLog ?? true
const rules = options?.rules || defaultACLRules
const logger = (entity: string, operation: string, success: boolean, message?: string) => {
if (auditLog) {
logAudit(entity, operation, success, user, message)
}
}
return {
baseAdapter,
user,
rules,
auditLog,
logger,
}
}

View File

@@ -0,0 +1,41 @@
import type { ListOptions, ListResult } from '../../core/foundation/types'
import type { ACLContext } from './context'
import { enforceRowAccess, withAudit } from './guards'
export const createEntity = (context: ACLContext) => async (entity: string, data: Record<string, unknown>) => {
return withAudit(context, entity, 'create', () => context.baseAdapter.create(entity, data))
}
export const readEntity = (context: ACLContext) => async (entity: string, id: string) => {
return withAudit(context, entity, 'read', async () => {
const result = await context.baseAdapter.read(entity, id)
if (result) {
enforceRowAccess(context, entity, 'read', result as Record<string, unknown>)
}
return result
})
}
export const updateEntity = (context: ACLContext) => async (entity: string, id: string, data: Record<string, unknown>) => {
return withAudit(context, entity, 'update', async () => {
const existing = await context.baseAdapter.read(entity, id)
if (existing) {
enforceRowAccess(context, entity, 'update', existing as Record<string, unknown>)
}
return context.baseAdapter.update(entity, id, data)
})
}
export const deleteEntity = (context: ACLContext) => async (entity: string, id: string) => {
return withAudit(context, entity, 'delete', async () => {
const existing = await context.baseAdapter.read(entity, id)
if (existing) {
enforceRowAccess(context, entity, 'delete', existing as Record<string, unknown>)
}
return context.baseAdapter.delete(entity, id)
})
}
export const listEntities = (context: ACLContext) => async (entity: string, options?: ListOptions): Promise<ListResult<unknown>> => {
return withAudit(context, entity, 'list', () => context.baseAdapter.list(entity, options))
}

View File

@@ -0,0 +1,37 @@
import { checkPermission } from '../acl/check-permission'
import { checkRowLevelAccess } from '../acl/check-row-level-access'
import { resolvePermissionOperation } from '../acl/resolve-permission-operation'
import type { ACLContext } from './types'
export const enforcePermission = (context: ACLContext, entity: string, operation: string) => {
checkPermission(entity, operation, context.user, context.rules, context.logger)
}
export const enforceRowAccess = (
context: ACLContext,
entity: string,
operation: string,
record: Record<string, unknown>,
) => {
checkRowLevelAccess(entity, operation, record, context.user, context.rules, context.logger)
}
export const withAudit = async <T>(
context: ACLContext,
entity: string,
operation: string,
action: () => Promise<T>,
) => {
enforcePermission(context, entity, operation)
try {
const result = await action()
context.logger(entity, operation, true)
return result
} catch (error) {
context.logger(entity, operation, false, (error as Error).message)
throw error
}
}
export const resolveOperation = resolvePermissionOperation

View File

@@ -0,0 +1,3 @@
export { ACLAdapter } from './acl-adapter'
export type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types'
export { defaultACLRules } from '../acl/default-rules'

View File

@@ -0,0 +1,48 @@
import type { ListOptions, ListResult } from '../../core/foundation/types'
import { enforceRowAccess, resolveOperation, withAudit } from './guards'
import type { ACLContext } from './types'
export const createReadStrategy = (context: ACLContext) => {
const read = async (entity: string, id: string): Promise<unknown | null> => {
return withAudit(context, entity, 'read', async () => {
const result = await context.baseAdapter.read(entity, id)
if (result) {
enforceRowAccess(context, entity, 'read', result as Record<string, unknown>)
}
return result
})
}
const list = async (entity: string, options?: ListOptions): Promise<ListResult<unknown>> => {
return withAudit(context, entity, 'list', () => context.baseAdapter.list(entity, options))
}
const findFirst = async (entity: string, filter?: Record<string, unknown>): Promise<unknown | null> => {
const operation = resolveOperation('findFirst')
return withAudit(context, entity, operation, async () => {
const result = await context.baseAdapter.findFirst(entity, filter)
if (result) {
enforceRowAccess(context, entity, operation, result as Record<string, unknown>)
}
return result
})
}
const findByField = async (entity: string, field: string, value: unknown): Promise<unknown | null> => {
const operation = resolveOperation('findByField')
return withAudit(context, entity, operation, async () => {
const result = await context.baseAdapter.findByField(entity, field, value)
if (result) {
enforceRowAccess(context, entity, operation, result as Record<string, unknown>)
}
return result
})
}
return {
read,
list,
findFirst,
findByField,
}
}

View File

@@ -0,0 +1,27 @@
import type { DBALAdapter } from '../adapter'
export interface User {
id: string
username: string
role: 'user' | 'admin' | 'god' | 'supergod'
}
export interface ACLRule {
entity: string
roles: string[]
operations: string[]
rowLevelFilter?: (user: User, data: Record<string, unknown>) => boolean
}
export interface ACLAdapterOptions {
rules?: ACLRule[]
auditLog?: boolean
}
export interface ACLContext {
baseAdapter: DBALAdapter
user: User
rules: ACLRule[]
auditLog: boolean
logger: (entity: string, operation: string, success: boolean, message?: string) => void
}

View File

@@ -0,0 +1,83 @@
import { enforceRowAccess, resolveOperation, withAudit } from './guards'
import type { ACLContext } from './types'
export const createWriteStrategy = (context: ACLContext) => {
const create = async (entity: string, data: Record<string, unknown>): Promise<unknown> => {
return withAudit(context, entity, 'create', () => context.baseAdapter.create(entity, data))
}
const update = async (entity: string, id: string, data: Record<string, unknown>): Promise<unknown> => {
return withAudit(context, entity, 'update', async () => {
const existing = await context.baseAdapter.read(entity, id)
if (existing) {
enforceRowAccess(context, entity, 'update', existing as Record<string, unknown>)
}
return context.baseAdapter.update(entity, id, data)
})
}
const remove = async (entity: string, id: string): Promise<boolean> => {
return withAudit(context, entity, 'delete', async () => {
const existing = await context.baseAdapter.read(entity, id)
if (existing) {
enforceRowAccess(context, entity, 'delete', existing as Record<string, unknown>)
}
return context.baseAdapter.delete(entity, id)
})
}
const upsert = async (
entity: string,
filter: Record<string, unknown>,
createData: Record<string, unknown>,
updateData: Record<string, unknown>,
): Promise<unknown> => {
return withAudit(context, entity, 'upsert', () => context.baseAdapter.upsert(entity, filter, createData, updateData))
}
const updateByField = async (
entity: string,
field: string,
value: unknown,
data: Record<string, unknown>,
): Promise<unknown> => {
const operation = resolveOperation('updateByField')
return withAudit(context, entity, operation, () => context.baseAdapter.updateByField(entity, field, value, data))
}
const deleteByField = async (entity: string, field: string, value: unknown): Promise<boolean> => {
const operation = resolveOperation('deleteByField')
return withAudit(context, entity, operation, () => context.baseAdapter.deleteByField(entity, field, value))
}
const createMany = async (entity: string, data: Record<string, unknown>[]): Promise<number> => {
const operation = resolveOperation('createMany')
return withAudit(context, entity, operation, () => context.baseAdapter.createMany(entity, data))
}
const updateMany = async (
entity: string,
filter: Record<string, unknown>,
data: Record<string, unknown>,
): Promise<number> => {
const operation = resolveOperation('updateMany')
return withAudit(context, entity, operation, () => context.baseAdapter.updateMany(entity, filter, data))
}
const deleteMany = async (entity: string, filter?: Record<string, unknown>): Promise<number> => {
const operation = resolveOperation('deleteMany')
return withAudit(context, entity, operation, () => context.baseAdapter.deleteMany(entity, filter))
}
return {
create,
update,
delete: remove,
upsert,
updateByField,
deleteByField,
createMany,
updateMany,
deleteMany,
}
}

View File

@@ -3,7 +3,7 @@
* @description Audit logging for ACL operations
*/
import type { User } from './types'
import type { User } from '../acl-adapter/types'
/**
* Log audit entry for ACL operation

View File

@@ -4,7 +4,7 @@
*/
import { DBALError } from '../../core/foundation/errors'
import type { User, ACLRule } from './types'
import type { ACLRule, User } from '../acl-adapter/types'
/**
* Check if user has permission to perform operation on entity

View File

@@ -4,7 +4,7 @@
*/
import { DBALError } from '../../core/foundation/errors'
import type { User, ACLRule } from './types'
import type { ACLRule, User } from '../acl-adapter/types'
/**
* Check row-level access for specific data

View File

@@ -3,7 +3,7 @@
* @description Default ACL rules for entities
*/
import type { ACLRule } from './types'
import type { ACLRule } from '../acl-adapter/types'
export const defaultACLRules: ACLRule[] = [
{

View File

@@ -1,230 +1 @@
import type {
BlobStorage,
BlobMetadata,
BlobListResult,
UploadOptions,
DownloadOptions,
BlobListOptions,
} from '../blob-storage'
import { DBALError } from '../../core/foundation/errors'
import { createHash } from 'crypto'
interface BlobData {
data: Buffer
contentType: string
etag: string
lastModified: Date
metadata: Record<string, string>
}
/**
* In-memory blob storage implementation
* Useful for testing and development
*/
export class MemoryStorage implements BlobStorage {
private store: Map<string, BlobData> = new Map()
async upload(
key: string,
data: Buffer | Uint8Array,
options: UploadOptions = {}
): Promise<BlobMetadata> {
const buffer = Buffer.from(data)
if (!options.overwrite && this.store.has(key)) {
throw DBALError.conflict(`Blob already exists: ${key}`)
}
const blob: BlobData = {
data: buffer,
contentType: options.contentType || 'application/octet-stream',
etag: this.generateEtag(buffer),
lastModified: new Date(),
metadata: options.metadata || {},
}
this.store.set(key, blob)
return this.makeBlobMetadata(key, blob)
}
async uploadStream(
key: string,
stream: ReadableStream | NodeJS.ReadableStream,
size: number,
options: UploadOptions = {}
): Promise<BlobMetadata> {
// Collect stream data into buffer
const chunks: Buffer[] = []
if ('getReader' in stream) {
// Web ReadableStream
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(Buffer.from(value))
}
} else {
// Node.js ReadableStream
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk))
}
}
const buffer = Buffer.concat(chunks)
return this.upload(key, buffer, options)
}
async download(
key: string,
options: DownloadOptions = {}
): Promise<Buffer> {
const blob = this.store.get(key)
if (!blob) {
throw DBALError.notFound(`Blob not found: ${key}`)
}
let data = blob.data
if (options.offset !== undefined || options.length !== undefined) {
const offset = options.offset || 0
const length = options.length || (data.length - offset)
if (offset >= data.length) {
throw DBALError.validationError('Offset exceeds blob size')
}
data = data.subarray(offset, offset + length)
}
return data
}
async downloadStream(
key: string,
options: DownloadOptions = {}
): Promise<ReadableStream | NodeJS.ReadableStream> {
const data = await this.download(key, options)
// Return a readable stream
if (typeof ReadableStream !== 'undefined') {
// Web ReadableStream
return new ReadableStream({
start(controller) {
controller.enqueue(data)
controller.close()
},
})
} else {
// Node.js ReadableStream
const { Readable } = await import('stream')
return Readable.from(data)
}
}
async delete(key: string): Promise<boolean> {
if (!this.store.has(key)) {
throw DBALError.notFound(`Blob not found: ${key}`)
}
this.store.delete(key)
return true
}
async exists(key: string): Promise<boolean> {
return this.store.has(key)
}
async getMetadata(key: string): Promise<BlobMetadata> {
const blob = this.store.get(key)
if (!blob) {
throw DBALError.notFound(`Blob not found: ${key}`)
}
return this.makeBlobMetadata(key, blob)
}
async list(options: BlobListOptions = {}): Promise<BlobListResult> {
const prefix = options.prefix || ''
const maxKeys = options.maxKeys || 1000
const items: BlobMetadata[] = []
let nextToken: string | undefined
for (const [key, blob] of this.store.entries()) {
if (!prefix || key.startsWith(prefix)) {
if (items.length >= maxKeys) {
nextToken = key
break
}
items.push(this.makeBlobMetadata(key, blob))
}
}
return {
items,
nextToken,
isTruncated: nextToken !== undefined,
}
}
async generatePresignedUrl(
key: string,
expirationSeconds: number = 3600
): Promise<string> {
// Memory storage doesn't support presigned URLs
return ''
}
async copy(
sourceKey: string,
destKey: string
): Promise<BlobMetadata> {
const sourceBlob = this.store.get(sourceKey)
if (!sourceBlob) {
throw DBALError.notFound(`Source blob not found: ${sourceKey}`)
}
const destBlob: BlobData = {
...sourceBlob,
data: Buffer.from(sourceBlob.data),
lastModified: new Date(),
}
this.store.set(destKey, destBlob)
return this.makeBlobMetadata(destKey, destBlob)
}
async getTotalSize(): Promise<number> {
let total = 0
for (const blob of this.store.values()) {
total += blob.data.length
}
return total
}
async getObjectCount(): Promise<number> {
return this.store.size
}
private generateEtag(data: Buffer): string {
const hash = createHash('md5').update(data).digest('hex')
return `"${hash}"`
}
private makeBlobMetadata(key: string, blob: BlobData): BlobMetadata {
return {
key,
size: blob.data.length,
contentType: blob.contentType,
etag: blob.etag,
lastModified: blob.lastModified,
customMetadata: blob.metadata,
}
}
}
export { MemoryStorage } from './memory-storage/index'

View File

@@ -0,0 +1,48 @@
import { DBALError } from '../../core/foundation/errors'
import type { DownloadOptions } from '../blob-storage'
import type { MemoryStore } from './store'
import { getBlobOrThrow, normalizeKey } from './utils'
export const downloadBuffer = (
store: MemoryStore,
key: string,
options: DownloadOptions = {},
): Buffer => {
const normalizedKey = normalizeKey(key)
const blob = getBlobOrThrow(store, normalizedKey)
let data = blob.data
if (options.offset !== undefined || options.length !== undefined) {
const offset = options.offset || 0
const length = options.length || (data.length - offset)
if (offset >= data.length) {
throw DBALError.validationError('Offset exceeds blob size')
}
data = data.subarray(offset, offset + length)
}
return data
}
export const downloadStream = async (
store: MemoryStore,
key: string,
options?: DownloadOptions,
) => {
const data = downloadBuffer(store, key, options)
if (typeof ReadableStream !== 'undefined') {
return new ReadableStream({
start(controller) {
controller.enqueue(data)
controller.close()
},
})
}
const { Readable } = await import('stream')
return Readable.from(data)
}

View File

@@ -0,0 +1,73 @@
import type {
BlobStorage,
BlobMetadata,
BlobListResult,
UploadOptions,
DownloadOptions,
BlobListOptions,
} from '../blob-storage'
import { createStore } from './store'
import { uploadBuffer, uploadFromStream } from './uploads'
import { downloadBuffer, downloadStream } from './downloads'
import { copyBlob, deleteBlob, getMetadata, listBlobs, getObjectCount, getTotalSize } from './management'
import { normalizeKey } from './utils'
export class MemoryStorage implements BlobStorage {
private store = createStore()
async upload(key: string, data: Buffer | Uint8Array, options: UploadOptions = {}): Promise<BlobMetadata> {
return uploadBuffer(this.store, key, data, options)
}
async uploadStream(
key: string,
stream: ReadableStream | NodeJS.ReadableStream,
_size: number,
options: UploadOptions = {},
): Promise<BlobMetadata> {
return uploadFromStream(this.store, key, stream, options)
}
async download(key: string, options: DownloadOptions = {}): Promise<Buffer> {
return downloadBuffer(this.store, key, options)
}
async downloadStream(
key: string,
options: DownloadOptions = {},
): Promise<ReadableStream | NodeJS.ReadableStream> {
return downloadStream(this.store, key, options)
}
async delete(key: string): Promise<boolean> {
return deleteBlob(this.store, key)
}
async exists(key: string): Promise<boolean> {
return this.store.has(normalizeKey(key))
}
async getMetadata(key: string): Promise<BlobMetadata> {
return getMetadata(this.store, key)
}
async list(options: BlobListOptions = {}): Promise<BlobListResult> {
return listBlobs(this.store, options)
}
async generatePresignedUrl(_key: string, _expirationSeconds: number = 3600): Promise<string> {
return ''
}
async copy(sourceKey: string, destKey: string): Promise<BlobMetadata> {
return copyBlob(this.store, sourceKey, destKey)
}
async getTotalSize(): Promise<number> {
return getTotalSize(this.store)
}
async getObjectCount(): Promise<number> {
return getObjectCount(this.store)
}
}

View File

@@ -0,0 +1,72 @@
import { DBALError } from '../../core/foundation/errors'
import type { BlobListOptions, BlobListResult, BlobMetadata } from '../blob-storage'
import type { MemoryStore } from './store'
import { toBlobMetadata } from './serialization'
import { cleanupStoreEntry, getBlobOrThrow, normalizeKey } from './utils'
export const deleteBlob = async (store: MemoryStore, key: string): Promise<boolean> => {
const normalizedKey = normalizeKey(key)
if (!store.has(normalizedKey)) {
throw DBALError.notFound(`Blob not found: ${normalizedKey}`)
}
cleanupStoreEntry(store, normalizedKey)
return true
}
export const getMetadata = (store: MemoryStore, key: string): BlobMetadata => {
const normalizedKey = normalizeKey(key)
const blob = getBlobOrThrow(store, normalizedKey)
return toBlobMetadata(normalizedKey, blob)
}
export const listBlobs = (store: MemoryStore, options: BlobListOptions = {}): BlobListResult => {
const prefix = options.prefix ? normalizeKey(options.prefix) : ''
const maxKeys = options.maxKeys || 1000
const items: BlobMetadata[] = []
let nextToken: string | undefined
for (const [key, blob] of store.entries()) {
if (!prefix || key.startsWith(prefix)) {
if (items.length >= maxKeys) {
nextToken = key
break
}
items.push(toBlobMetadata(key, blob))
}
}
return {
items,
nextToken,
isTruncated: nextToken !== undefined,
}
}
export const copyBlob = (store: MemoryStore, sourceKey: string, destKey: string): BlobMetadata => {
const normalizedSourceKey = normalizeKey(sourceKey)
const normalizedDestKey = normalizeKey(destKey)
const sourceBlob = getBlobOrThrow(store, normalizedSourceKey)
const destBlob = {
...sourceBlob,
data: Buffer.from(sourceBlob.data),
lastModified: new Date(),
}
store.set(normalizedDestKey, destBlob)
return toBlobMetadata(normalizedDestKey, destBlob)
}
export const getTotalSize = (store: MemoryStore): number => {
let total = 0
for (const blob of store.values()) {
total += blob.data.length
}
return total
}
export const getObjectCount = (store: MemoryStore): number => store.size

View File

@@ -0,0 +1,43 @@
import { createHash } from 'crypto'
import type { UploadOptions, BlobMetadata } from '../blob-storage'
import type { BlobData } from './store'
export const generateEtag = (data: Buffer): string => `"${createHash('md5').update(data).digest('hex')}"`
export const toBlobData = (data: Buffer, options: UploadOptions = {}): BlobData => ({
data,
contentType: options.contentType || 'application/octet-stream',
etag: generateEtag(data),
lastModified: new Date(),
metadata: options.metadata || {},
})
export const toBlobMetadata = (key: string, blob: BlobData): BlobMetadata => ({
key,
size: blob.data.length,
contentType: blob.contentType,
etag: blob.etag,
lastModified: blob.lastModified,
customMetadata: blob.metadata,
})
export const collectStream = async (
stream: ReadableStream | NodeJS.ReadableStream,
): Promise<Buffer> => {
const chunks: Buffer[] = []
if ('getReader' in stream) {
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(Buffer.from(value))
}
} else {
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk))
}
}
return Buffer.concat(chunks)
}

View File

@@ -0,0 +1,11 @@
export interface BlobData {
data: Buffer
contentType: string
etag: string
lastModified: Date
metadata: Record<string, string>
}
export type MemoryStore = Map<string, BlobData>
export const createStore = (): MemoryStore => new Map()

View File

@@ -0,0 +1,34 @@
import { DBALError } from '../../core/foundation/errors'
import type { UploadOptions } from '../blob-storage'
import type { MemoryStore } from './store'
import { collectStream, toBlobData, toBlobMetadata } from './serialization'
import { normalizeKey } from './utils'
export const uploadBuffer = (
store: MemoryStore,
key: string,
data: Buffer | Uint8Array,
options: UploadOptions = {},
) => {
const normalizedKey = normalizeKey(key)
const buffer = Buffer.from(data)
if (!options.overwrite && store.has(normalizedKey)) {
throw DBALError.conflict(`Blob already exists: ${normalizedKey}`)
}
const blob = toBlobData(buffer, options)
store.set(normalizedKey, blob)
return toBlobMetadata(normalizedKey, blob)
}
export const uploadFromStream = async (
store: MemoryStore,
key: string,
stream: ReadableStream | NodeJS.ReadableStream,
options?: UploadOptions,
) => {
const buffer = await collectStream(stream)
return uploadBuffer(store, key, buffer, options)
}

View File

@@ -0,0 +1,18 @@
import { DBALError } from '../../core/foundation/errors'
import type { BlobData, MemoryStore } from './store'
export const normalizeKey = (key: string): string => key.replace(/^\/+/, '').trim()
export const getBlobOrThrow = (store: MemoryStore, key: string): BlobData => {
const blob = store.get(key)
if (!blob) {
throw DBALError.notFound(`Blob not found: ${key}`)
}
return blob
}
export const cleanupStoreEntry = (store: MemoryStore, key: string): void => {
store.delete(key)
}

View File

@@ -1,260 +1,5 @@
/**
* Tenant-Aware Blob Storage
*
* Wraps BlobStorage with multi-tenant support including:
* - Namespace isolation
* - Access control
* - Quota management
* - Virtual root directories
*/
import { BlobStorage, BlobMetadata, UploadOptions, DownloadOptions, BlobListOptions, BlobListResult } from '../blob-storage'
import { TenantContext, TenantManager } from '../core/tenant-context'
import { DBALError } from '../../core/foundation/errors'
import { Readable } from 'stream'
export class TenantAwareBlobStorage implements BlobStorage {
constructor(
private readonly baseStorage: BlobStorage,
private readonly tenantManager: TenantManager,
private readonly tenantId: string,
private readonly userId: string
) {}
private async getContext(): Promise<TenantContext> {
return this.tenantManager.getTenantContext(this.tenantId, this.userId)
}
private getScopedKey(key: string, namespace: string): string {
// Remove leading slash if present
const cleanKey = key.startsWith('/') ? key.substring(1) : key
return `${namespace}${cleanKey}`
}
private unscopeKey(scopedKey: string, namespace: string): string {
if (scopedKey.startsWith(namespace)) {
return scopedKey.substring(namespace.length)
}
return scopedKey
}
async upload(key: string, data: Buffer, options?: UploadOptions): Promise<BlobMetadata> {
const context = await this.getContext()
// Check permissions
if (!context.canWrite('blob')) {
throw DBALError.forbidden('Permission denied: cannot upload blobs')
}
// Check quota
const size = data.length
if (!context.canUploadBlob(size)) {
throw DBALError.rateLimitExceeded()
}
const scopedKey = this.getScopedKey(key, context.namespace)
const metadata = await this.baseStorage.upload(scopedKey, data, options)
// Update quota
await this.tenantManager.updateBlobUsage(this.tenantId, size, 1)
// Return metadata with unscoped key
return {
...metadata,
key
}
}
async uploadStream(key: string, stream: Readable, size: number, options?: UploadOptions): Promise<BlobMetadata> {
const context = await this.getContext()
// Check permissions
if (!context.canWrite('blob')) {
throw DBALError.forbidden('Permission denied: cannot upload blobs')
}
// Check quota
if (!context.canUploadBlob(size)) {
throw DBALError.rateLimitExceeded()
}
const scopedKey = this.getScopedKey(key, context.namespace)
const metadata = await this.baseStorage.uploadStream(scopedKey, stream, size, options)
// Update quota
await this.tenantManager.updateBlobUsage(this.tenantId, size, 1)
// Return metadata with unscoped key
return {
...metadata,
key
}
}
async download(key: string): Promise<Buffer> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob')) {
throw DBALError.forbidden('Permission denied: cannot download blobs')
}
const scopedKey = this.getScopedKey(key, context.namespace)
return this.baseStorage.download(scopedKey)
}
async downloadStream(key: string, options?: DownloadOptions): Promise<ReadableStream | NodeJS.ReadableStream> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob')) {
throw DBALError.forbidden('Permission denied: cannot download blobs')
}
const scopedKey = this.getScopedKey(key, context.namespace)
return this.baseStorage.downloadStream(scopedKey, options)
}
async delete(key: string): Promise<boolean> {
const context = await this.getContext()
// Check permissions
if (!context.canDelete('blob')) {
throw DBALError.forbidden('Permission denied: cannot delete blobs')
}
const scopedKey = this.getScopedKey(key, context.namespace)
// Get metadata before deletion to update quota
try {
const metadata = await this.baseStorage.getMetadata(scopedKey)
const deleted = await this.baseStorage.delete(scopedKey)
if (deleted) {
// Update quota
await this.tenantManager.updateBlobUsage(this.tenantId, -metadata.size, -1)
}
return deleted
} catch (error) {
// If metadata fetch fails, try delete anyway
return this.baseStorage.delete(scopedKey)
}
}
async exists(key: string): Promise<boolean> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob')) {
throw DBALError.forbidden('Permission denied: cannot check blob existence')
}
const scopedKey = this.getScopedKey(key, context.namespace)
return this.baseStorage.exists(scopedKey)
}
async copy(sourceKey: string, destKey: string): Promise<BlobMetadata> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob') || !context.canWrite('blob')) {
throw DBALError.forbidden('Permission denied: cannot copy blobs')
}
// Get source metadata to check quota
const sourceScoped = this.getScopedKey(sourceKey, context.namespace)
const sourceMetadata = await this.baseStorage.getMetadata(sourceScoped)
// Check quota for destination
if (!context.canUploadBlob(sourceMetadata.size)) {
throw DBALError.rateLimitExceeded()
}
const destScoped = this.getScopedKey(destKey, context.namespace)
const metadata = await this.baseStorage.copy(sourceScoped, destScoped)
// Update quota
await this.tenantManager.updateBlobUsage(this.tenantId, sourceMetadata.size, 1)
return {
...metadata,
key: destKey
}
}
async list(options?: BlobListOptions): Promise<BlobListResult> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob')) {
throw DBALError.forbidden('Permission denied: cannot list blobs')
}
// Add namespace prefix to options
const scopedOptions: BlobListOptions = {
...options,
prefix: options?.prefix
? this.getScopedKey(options.prefix, context.namespace)
: context.namespace
}
const result = await this.baseStorage.list(scopedOptions)
// Unscope keys in results
return {
...result,
items: result.items.map(item => ({
...item,
key: this.unscopeKey(item.key, context.namespace)
}))
}
}
async getMetadata(key: string): Promise<BlobMetadata> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob')) {
throw DBALError.forbidden('Permission denied: cannot get blob metadata')
}
const scopedKey = this.getScopedKey(key, context.namespace)
const metadata = await this.baseStorage.getMetadata(scopedKey)
return {
...metadata,
key
}
}
async getStats(): Promise<{ count: number; totalSize: number }> {
const context = await this.getContext()
// Return tenant's current usage from quota
return {
count: context.quota.currentBlobCount,
totalSize: context.quota.currentBlobStorageBytes
}
}
async generatePresignedUrl(key: string, expiresIn: number): Promise<string> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob')) {
throw DBALError.forbidden('Permission denied: cannot generate presigned URL')
}
const scopedKey = this.getScopedKey(key, context.namespace)
return this.baseStorage.generatePresignedUrl(scopedKey, expiresIn)
}
async getTotalSize(): Promise<number> {
return this.baseStorage.getTotalSize()
}
async getObjectCount(): Promise<number> {
return this.baseStorage.getObjectCount()
}
}
export { TenantAwareBlobStorage } from './tenant-aware-storage/index'
export type { TenantAwareDeps } from './tenant-aware-storage/context'
export { scopeKey, unscopeKey } from './tenant-aware-storage/context'
export { ensurePermission, resolveTenantContext } from './tenant-aware-storage/tenant-context'
export { auditCopy, auditDeletion, auditUpload } from './tenant-aware-storage/audit-hooks'

View File

@@ -0,0 +1,17 @@
import type { TenantAwareDeps } from './context'
const recordUsageChange = async (deps: TenantAwareDeps, bytesChange: number, countChange: number): Promise<void> => {
await deps.tenantManager.updateBlobUsage(deps.tenantId, bytesChange, countChange)
}
export const auditUpload = async (deps: TenantAwareDeps, sizeBytes: number): Promise<void> => {
await recordUsageChange(deps, sizeBytes, 1)
}
export const auditDeletion = async (deps: TenantAwareDeps, sizeBytes: number): Promise<void> => {
await recordUsageChange(deps, -sizeBytes, -1)
}
export const auditCopy = async (deps: TenantAwareDeps, sizeBytes: number): Promise<void> => {
await recordUsageChange(deps, sizeBytes, 1)
}

View File

@@ -0,0 +1,21 @@
import type { TenantManager } from '../../core/foundation/tenant-context'
import type { BlobStorage } from '../blob-storage'
export interface TenantAwareDeps {
baseStorage: BlobStorage
tenantManager: TenantManager
tenantId: string
userId: string
}
export const scopeKey = (key: string, namespace: string): string => {
const cleanKey = key.startsWith('/') ? key.substring(1) : key
return `${namespace}${cleanKey}`
}
export const unscopeKey = (scopedKey: string, namespace: string): string => {
if (scopedKey.startsWith(namespace)) {
return scopedKey.substring(namespace.length)
}
return scopedKey
}

View File

@@ -0,0 +1,66 @@
import type { BlobListOptions, BlobListResult, BlobMetadata, BlobStorage, DownloadOptions, UploadOptions } from '../blob-storage'
import type { TenantManager } from '../../core/foundation/tenant-context'
import type { TenantAwareDeps } from './context'
import { deleteBlob, exists, copyBlob, getStats } from './mutations'
import { downloadBuffer, downloadStream, generatePresignedUrl, getMetadata, listBlobs } from './reads'
import { uploadBuffer, uploadStream } from './uploads'
export class TenantAwareBlobStorage implements BlobStorage {
private readonly deps: TenantAwareDeps
constructor(baseStorage: BlobStorage, tenantManager: TenantManager, tenantId: string, userId: string) {
this.deps = { baseStorage, tenantManager, tenantId, userId }
}
async upload(key: string, data: Buffer, options?: UploadOptions): Promise<BlobMetadata> {
return uploadBuffer(this.deps, key, data, options)
}
async uploadStream(key: string, stream: NodeJS.ReadableStream, size: number, options?: UploadOptions): Promise<BlobMetadata> {
return uploadStream(this.deps, key, stream, size, options)
}
async download(key: string): Promise<Buffer> {
return downloadBuffer(this.deps, key)
}
async downloadStream(key: string, options?: DownloadOptions): Promise<ReadableStream | NodeJS.ReadableStream> {
return downloadStream(this.deps, key, options)
}
async delete(key: string): Promise<boolean> {
return deleteBlob(this.deps, key)
}
async exists(key: string): Promise<boolean> {
return exists(this.deps, key)
}
async copy(sourceKey: string, destKey: string): Promise<BlobMetadata> {
return copyBlob(this.deps, sourceKey, destKey)
}
async list(options?: BlobListOptions): Promise<BlobListResult> {
return listBlobs(this.deps, options)
}
async getMetadata(key: string): Promise<BlobMetadata> {
return getMetadata(this.deps, key)
}
async getStats(): Promise<{ count: number; totalSize: number }> {
return getStats(this.deps)
}
async generatePresignedUrl(key: string, expiresIn: number): Promise<string> {
return generatePresignedUrl(this.deps, key, expiresIn)
}
async getTotalSize(): Promise<number> {
return this.deps.baseStorage.getTotalSize()
}
async getObjectCount(): Promise<number> {
return this.deps.baseStorage.getObjectCount()
}
}

View File

@@ -0,0 +1,69 @@
import { DBALError } from '../../core/foundation/errors'
import type { BlobMetadata } from '../blob-storage'
import { auditCopy, auditDeletion } from './audit-hooks'
import type { TenantAwareDeps } from './context'
import { scopeKey } from './context'
import { ensurePermission, resolveTenantContext } from './tenant-context'
export const deleteBlob = async (deps: TenantAwareDeps, key: string): Promise<boolean> => {
const context = await resolveTenantContext(deps)
ensurePermission(context, 'delete')
const scopedKey = scopeKey(key, context.namespace)
try {
const metadata = await deps.baseStorage.getMetadata(scopedKey)
const deleted = await deps.baseStorage.delete(scopedKey)
if (deleted) {
await auditDeletion(deps, metadata.size)
}
return deleted
} catch {
return deps.baseStorage.delete(scopedKey)
}
}
export const exists = async (deps: TenantAwareDeps, key: string): Promise<boolean> => {
const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
return deps.baseStorage.exists(scopedKey)
}
export const copyBlob = async (
deps: TenantAwareDeps,
sourceKey: string,
destKey: string,
): Promise<BlobMetadata> => {
const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
ensurePermission(context, 'write')
const sourceScoped = scopeKey(sourceKey, context.namespace)
const sourceMetadata = await deps.baseStorage.getMetadata(sourceScoped)
if (!context.canUploadBlob(sourceMetadata.size)) {
throw DBALError.rateLimitExceeded()
}
const destScoped = scopeKey(destKey, context.namespace)
const metadata = await deps.baseStorage.copy(sourceScoped, destScoped)
await auditCopy(deps, sourceMetadata.size)
return {
...metadata,
key: destKey,
}
}
export const getStats = async (deps: TenantAwareDeps) => {
const context = await resolveTenantContext(deps)
return {
count: context.quota.currentBlobCount,
totalSize: context.quota.currentBlobStorageBytes,
}
}

View File

@@ -0,0 +1,72 @@
import type { DownloadOptions, BlobMetadata, BlobListOptions, BlobListResult } from '../blob-storage'
import type { TenantAwareDeps } from './context'
import { scopeKey, unscopeKey } from './context'
import { ensurePermission, resolveTenantContext } from './tenant-context'
export const downloadBuffer = async (deps: TenantAwareDeps, key: string): Promise<Buffer> => {
const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
return deps.baseStorage.download(scopedKey)
}
export const downloadStream = async (
deps: TenantAwareDeps,
key: string,
options?: DownloadOptions,
): Promise<ReadableStream | NodeJS.ReadableStream> => {
const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
return deps.baseStorage.downloadStream(scopedKey, options)
}
export const listBlobs = async (
deps: TenantAwareDeps,
options: BlobListOptions = {},
): Promise<BlobListResult> => {
const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
const scopedOptions: BlobListOptions = {
...options,
prefix: options.prefix ? scopeKey(options.prefix, context.namespace) : context.namespace,
}
const result = await deps.baseStorage.list(scopedOptions)
return {
...result,
items: result.items.map(item => ({
...item,
key: unscopeKey(item.key, context.namespace),
})),
}
}
export const getMetadata = async (deps: TenantAwareDeps, key: string): Promise<BlobMetadata> => {
const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
const metadata = await deps.baseStorage.getMetadata(scopedKey)
return {
...metadata,
key,
}
}
export const generatePresignedUrl = async (
deps: TenantAwareDeps,
key: string,
expiresIn: number,
): Promise<string> => {
const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
return deps.baseStorage.generatePresignedUrl(scopedKey, expiresIn)
}

View File

@@ -0,0 +1,21 @@
import { DBALError } from '../../core/foundation/errors'
import type { TenantContext } from '../../core/foundation/tenant-context'
import type { TenantAwareDeps } from './context'
export const resolveTenantContext = async ({ tenantManager, tenantId, userId }: TenantAwareDeps): Promise<TenantContext> => {
return tenantManager.getTenantContext(tenantId, userId)
}
export const ensurePermission = (context: TenantContext, action: 'read' | 'write' | 'delete'): void => {
const accessCheck =
action === 'read' ? context.canRead('blob') : action === 'write' ? context.canWrite('blob') : context.canDelete('blob')
if (!accessCheck) {
const verbs: Record<typeof action, string> = {
read: 'read',
write: 'write',
delete: 'delete',
}
throw DBALError.forbidden(`Permission denied: cannot ${verbs[action]} blobs`)
}
}

View File

@@ -0,0 +1,53 @@
import { DBALError } from '../../core/foundation/errors'
import { auditUpload } from './audit-hooks'
import type { TenantAwareDeps } from './context'
import { scopeKey } from './context'
import { ensurePermission, resolveTenantContext } from './tenant-context'
import type { UploadOptions, BlobMetadata } from '../blob-storage'
export const uploadBuffer = async (
deps: TenantAwareDeps,
key: string,
data: Buffer,
options?: UploadOptions,
): Promise<BlobMetadata> => {
const context = await resolveTenantContext(deps)
ensurePermission(context, 'write')
if (!context.canUploadBlob(data.length)) {
throw DBALError.rateLimitExceeded()
}
const scopedKey = scopeKey(key, context.namespace)
const metadata = await deps.baseStorage.upload(scopedKey, data, options)
await auditUpload(deps, data.length)
return {
...metadata,
key,
}
}
export const uploadStream = async (
deps: TenantAwareDeps,
key: string,
stream: NodeJS.ReadableStream,
size: number,
options?: UploadOptions,
): Promise<BlobMetadata> => {
const context = await resolveTenantContext(deps)
ensurePermission(context, 'write')
if (!context.canUploadBlob(size)) {
throw DBALError.rateLimitExceeded()
}
const scopedKey = scopeKey(key, context.namespace)
const metadata = await deps.baseStorage.uploadStream(scopedKey, stream, size, options)
await auditUpload(deps, size)
return {
...metadata,
key,
}
}

View File

@@ -1,168 +1 @@
/**
* @file websocket-bridge.ts
* @description WebSocket bridge adapter for remote DBAL daemon
*/
import type { DBALAdapter, AdapterCapabilities } from '../adapters/adapter'
import type { ListOptions, ListResult } from '../core/types'
import { DBALError } from '../core/foundation/errors'
import { generateRequestId } from './utils/generate-request-id'
import type { RPCMessage, RPCResponse, PendingRequest } from './utils/rpc-types'
export class WebSocketBridge implements DBALAdapter {
private ws: WebSocket | null = null
private endpoint: string
private auth?: { user: unknown, session: unknown }
private pendingRequests = new Map<string, PendingRequest>()
constructor(endpoint: string, auth?: { user: unknown, session: unknown }) {
this.endpoint = endpoint
this.auth = auth
}
private async connect(): Promise<void> {
if (this.ws?.readyState === WebSocket.OPEN) {
return
}
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.endpoint)
this.ws.onopen = () => {
resolve()
}
this.ws.onerror = (error) => {
reject(DBALError.internal(`WebSocket connection failed: ${error}`))
}
this.ws.onmessage = (event) => {
this.handleMessage(event.data)
}
this.ws.onclose = () => {
this.ws = null
}
})
}
private handleMessage(data: string): void {
try {
const response: RPCResponse = JSON.parse(data)
const pending = this.pendingRequests.get(response.id)
if (!pending) {
return
}
this.pendingRequests.delete(response.id)
if (response.error) {
const error = new DBALError(
response.error.message,
response.error.code,
response.error.details
)
pending.reject(error)
} else {
pending.resolve(response.result)
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
}
private async call(method: string, ...params: unknown[]): Promise<unknown> {
await this.connect()
const id = generateRequestId()
const message: RPCMessage = { id, method, params }
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject })
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message))
} else {
this.pendingRequests.delete(id)
reject(DBALError.internal('WebSocket connection not open'))
}
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id)
reject(DBALError.timeout('Request timed out'))
}
}, 30000)
})
}
async create(entity: string, data: Record<string, unknown>): Promise<unknown> {
return this.call('create', entity, data)
}
async read(entity: string, id: string): Promise<unknown | null> {
return this.call('read', entity, id)
}
async update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
return this.call('update', entity, id, data)
}
async delete(entity: string, id: string): Promise<boolean> {
return this.call('delete', entity, id) as Promise<boolean>
}
async list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
return this.call('list', entity, options) as Promise<ListResult<unknown>>
}
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
return this.call('findFirst', entity, filter)
}
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
return this.call('findByField', entity, field, value)
}
async upsert(
entity: string,
filter: Record<string, unknown>,
createData: Record<string, unknown>,
updateData: Record<string, unknown>
): Promise<unknown> {
return this.call('upsert', entity, filter, createData, updateData)
}
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
return this.call('updateByField', entity, field, value, data)
}
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
return this.call('deleteByField', entity, field, value) as Promise<boolean>
}
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
return this.call('deleteMany', entity, filter) as Promise<number>
}
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
return this.call('createMany', entity, data) as Promise<number>
}
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
return this.call('updateMany', entity, filter, data) as Promise<number>
}
async getCapabilities(): Promise<AdapterCapabilities> {
return this.call('getCapabilities') as Promise<AdapterCapabilities>
}
async close(): Promise<void> {
if (this.ws) {
this.ws.close()
this.ws = null
}
this.pendingRequests.clear()
}
}
export { WebSocketBridge } from './websocket-bridge/index'

View File

@@ -0,0 +1,90 @@
import { DBALError } from '../../core/foundation/errors'
import type { RPCMessage } from '../utils/rpc-types'
import type { BridgeState } from './state'
import type { MessageRouter } from './message-router'
export interface ConnectionManager {
ensureConnection: () => Promise<void>
send: (message: RPCMessage) => Promise<void>
close: () => Promise<void>
}
export const createConnectionManager = (
state: BridgeState,
messageRouter: MessageRouter,
): ConnectionManager => {
let connectionPromise: Promise<void> | null = null
const resetConnection = () => {
connectionPromise = null
state.ws = null
}
const rejectPendingRequests = (error: DBALError) => {
state.pendingRequests.forEach(({ reject }) => reject(error))
state.pendingRequests.clear()
}
const ensureConnection = async (): Promise<void> => {
if (state.ws?.readyState === WebSocket.OPEN) {
return
}
if (connectionPromise) {
return connectionPromise
}
connectionPromise = new Promise((resolve, reject) => {
try {
const ws = new WebSocket(state.endpoint)
state.ws = ws
ws.onopen = () => resolve()
ws.onerror = error => {
const connectionError = DBALError.internal(`WebSocket connection failed: ${error}`)
rejectPendingRequests(connectionError)
resetConnection()
reject(connectionError)
}
ws.onclose = () => {
rejectPendingRequests(DBALError.internal('WebSocket connection closed'))
resetConnection()
}
ws.onmessage = event => messageRouter.handle(event.data)
} catch (error) {
resetConnection()
const connectionError =
error instanceof DBALError ? error : DBALError.internal('Failed to establish WebSocket connection')
reject(connectionError)
}
})
return connectionPromise
}
const send = async (message: RPCMessage): Promise<void> => {
await ensureConnection()
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
throw DBALError.internal('WebSocket connection not open')
}
state.ws.send(JSON.stringify(message))
}
const close = async (): Promise<void> => {
rejectPendingRequests(DBALError.internal('WebSocket connection closed'))
if (state.ws) {
state.ws.close()
}
resetConnection()
}
return {
ensureConnection,
send,
close,
}
}

View File

@@ -0,0 +1,84 @@
import type { DBALAdapter, AdapterCapabilities } from '../../adapters/adapter'
import type { ListOptions, ListResult } from '../../core/types'
import { createConnectionManager } from './connection-manager'
import { createMessageRouter } from './message-router'
import { createOperations } from './operations'
import { createBridgeState } from './state'
export class WebSocketBridge implements DBALAdapter {
private readonly state: ReturnType<typeof createBridgeState>
private readonly connectionManager: ReturnType<typeof createConnectionManager>
private readonly operations: ReturnType<typeof createOperations>
constructor(endpoint: string, auth?: { user: unknown; session: unknown }) {
this.state = createBridgeState(endpoint, auth)
const messageRouter = createMessageRouter(this.state)
this.connectionManager = createConnectionManager(this.state, messageRouter)
this.operations = createOperations(this.state, this.connectionManager)
}
create(entity: string, data: Record<string, unknown>): Promise<unknown> {
return this.operations.create(entity, data)
}
read(entity: string, id: string): Promise<unknown | null> {
return this.operations.read(entity, id) as Promise<unknown | null>
}
update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
return this.operations.update(entity, id, data)
}
delete(entity: string, id: string): Promise<boolean> {
return this.operations.delete(entity, id)
}
list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
return this.operations.list(entity, options)
}
findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
return this.operations.findFirst(entity, filter) as Promise<unknown | null>
}
findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
return this.operations.findByField(entity, field, value) as Promise<unknown | null>
}
upsert(
entity: string,
filter: Record<string, unknown>,
createData: Record<string, unknown>,
updateData: Record<string, unknown>,
): Promise<unknown> {
return this.operations.upsert(entity, filter, createData, updateData)
}
updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
return this.operations.updateByField(entity, field, value, data)
}
deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
return this.operations.deleteByField(entity, field, value)
}
deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
return this.operations.deleteMany(entity, filter)
}
createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
return this.operations.createMany(entity, data)
}
updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
return this.operations.updateMany(entity, filter, data)
}
getCapabilities(): Promise<AdapterCapabilities> {
return this.operations.getCapabilities()
}
async close(): Promise<void> {
await this.connectionManager.close()
}
}

View File

@@ -0,0 +1,68 @@
import { DBALError } from '../../core/foundation/errors'
import type { RPCResponse } from '../utils/rpc-types'
import type { BridgeState } from './state'
export interface MessageRouter {
handle: (rawMessage: unknown) => void
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const isRPCError = (value: unknown): value is NonNullable<RPCResponse['error']> =>
isRecord(value) &&
typeof value.code === 'number' &&
typeof value.message === 'string' &&
(value.details === undefined || isRecord(value.details))
const isRPCResponse = (value: unknown): value is RPCResponse => {
if (!isRecord(value)) {
return false
}
const hasId = typeof value.id === 'string'
const hasResult = Object.prototype.hasOwnProperty.call(value, 'result')
const hasError = isRPCError(value.error) || value.error === undefined
return hasId && (hasResult || isRPCError(value.error)) && hasError
}
const parseResponse = (rawMessage: string): RPCResponse => {
const parsed = JSON.parse(rawMessage) as unknown
if (!isRPCResponse(parsed)) {
throw new Error('Invalid RPC response shape')
}
return parsed
}
export const createMessageRouter = (state: BridgeState): MessageRouter => ({
handle: (rawMessage: unknown) => {
if (typeof rawMessage !== 'string') {
console.warn('Ignoring non-string WebSocket message')
return
}
try {
const response = parseResponse(rawMessage)
const pending = state.pendingRequests.get(response.id)
if (!pending) {
console.warn(`No pending request for response ${response.id}`)
return
}
state.pendingRequests.delete(response.id)
if (response.error) {
const error = new DBALError(response.error.message, response.error.code, response.error.details)
pending.reject(error)
} else {
pending.resolve(response.result)
}
} catch (error) {
console.error('Failed to process WebSocket message', error)
}
},
})

View File

@@ -0,0 +1,36 @@
import type { AdapterCapabilities } from '../../adapters/adapter'
import type { ListOptions, ListResult } from '../../core/types'
import type { ConnectionManager } from './connection-manager'
import type { BridgeState } from './state'
import { rpcCall } from './rpc'
export const createOperations = (state: BridgeState, connectionManager: ConnectionManager) => ({
create: (entity: string, data: Record<string, unknown>) => rpcCall(state, connectionManager, 'create', entity, data),
read: (entity: string, id: string) => rpcCall(state, connectionManager, 'read', entity, id),
update: (entity: string, id: string, data: Record<string, unknown>) =>
rpcCall(state, connectionManager, 'update', entity, id, data),
delete: (entity: string, id: string) => rpcCall(state, connectionManager, 'delete', entity, id) as Promise<boolean>,
list: (entity: string, options?: ListOptions) =>
rpcCall(state, connectionManager, 'list', entity, options) as Promise<ListResult<unknown>>,
findFirst: (entity: string, filter?: Record<string, unknown>) =>
rpcCall(state, connectionManager, 'findFirst', entity, filter),
findByField: (entity: string, field: string, value: unknown) =>
rpcCall(state, connectionManager, 'findByField', entity, field, value),
upsert: (
entity: string,
filter: Record<string, unknown>,
createData: Record<string, unknown>,
updateData: Record<string, unknown>,
) => rpcCall(state, connectionManager, 'upsert', entity, filter, createData, updateData),
updateByField: (entity: string, field: string, value: unknown, data: Record<string, unknown>) =>
rpcCall(state, connectionManager, 'updateByField', entity, field, value, data),
deleteByField: (entity: string, field: string, value: unknown) =>
rpcCall(state, connectionManager, 'deleteByField', entity, field, value) as Promise<boolean>,
deleteMany: (entity: string, filter?: Record<string, unknown>) =>
rpcCall(state, connectionManager, 'deleteMany', entity, filter) as Promise<number>,
createMany: (entity: string, data: Record<string, unknown>[]) =>
rpcCall(state, connectionManager, 'createMany', entity, data) as Promise<number>,
updateMany: (entity: string, filter: Record<string, unknown>, data: Record<string, unknown>) =>
rpcCall(state, connectionManager, 'updateMany', entity, filter, data) as Promise<number>,
getCapabilities: () => rpcCall(state, connectionManager, 'getCapabilities') as Promise<AdapterCapabilities>,
})

View File

@@ -0,0 +1,34 @@
import { DBALError } from '../../core/foundation/errors'
import { generateRequestId } from '../utils/generate-request-id'
import type { RPCMessage } from '../utils/rpc-types'
import type { ConnectionManager } from './connection-manager'
import type { BridgeState } from './state'
export const rpcCall = async (
state: BridgeState,
connectionManager: ConnectionManager,
method: string,
...params: unknown[]
): Promise<unknown> => {
const id = generateRequestId()
const message: RPCMessage = { id, method, params }
return new Promise((resolve, reject) => {
state.pendingRequests.set(id, { resolve, reject })
connectionManager
.send(message)
.catch(error => {
state.pendingRequests.delete(id)
reject(error)
return
})
setTimeout(() => {
if (state.pendingRequests.has(id)) {
state.pendingRequests.delete(id)
reject(DBALError.timeout('Request timed out'))
}
}, 30000)
})
}

View File

@@ -0,0 +1,18 @@
import type { PendingRequest } from '../utils/rpc-types'
export interface BridgeState {
ws: WebSocket | null
endpoint: string
auth?: { user: unknown; session: unknown }
pendingRequests: Map<string, PendingRequest>
}
export const createBridgeState = (
endpoint: string,
auth?: { user: unknown; session: unknown },
): BridgeState => ({
ws: null,
endpoint,
auth,
pendingRequests: new Map<string, PendingRequest>(),
})

View File

@@ -12,13 +12,13 @@ export * as luaScript from './lua-script';
export * as pkg from './package';
// Legacy factory exports (for backward compatibility)
export { createUserOperations } from './user-operations';
export { createPageOperations } from './page-operations';
export { createComponentOperations } from './component-operations';
export { createWorkflowOperations } from './workflow-operations';
export { createLuaScriptOperations } from './lua-script-operations';
export { createPackageOperations } from './package-operations';
export { createSessionOperations } from './session-operations';
export { createUserOperations } from './operations/core/user-operations';
export { createPageOperations } from './operations/system/page-operations';
export { createComponentOperations } from './operations/system/component-operations';
export { createWorkflowOperations } from './operations/core/workflow-operations';
export { createLuaScriptOperations } from './operations/core/lua-script-operations';
export { createPackageOperations } from './operations/system/package-operations';
export { createSessionOperations } from './operations/core/session-operations';
// Validation utilities
export * from '../validation';

View File

@@ -1,185 +1,11 @@
/**
* @file user-operations.ts
* @description User entity CRUD operations for DBAL client
*
* Single-responsibility module following the small-function-file pattern.
*/
export { createUserOperations } from './user'
export type { UserOperations } from './user'
import type { DBALAdapter } from '../../adapters/adapter'
import type { User, ListOptions, ListResult } from '../types'
import { DBALError } from '../errors'
import { validateUserCreate, validateUserUpdate, validateId } from '../validation'
/**
* Create user operations object for the DBAL client
*/
export const createUserOperations = (adapter: DBALAdapter) => ({
/**
* Create a new user
*/
create: async (data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> => {
const validationErrors = validateUserCreate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid user data',
validationErrors.map(error => ({ field: 'user', error }))
)
}
try {
return adapter.create('User', data) as Promise<User>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict(`User with username or email already exists`)
}
throw error
}
},
/**
* Read a user by ID
*/
read: async (id: string): Promise<User | null> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid user ID',
validationErrors.map(error => ({ field: 'id', error }))
)
}
const result = await adapter.read('User', id) as User | null
if (!result) {
throw DBALError.notFound(`User not found: ${id}`)
}
return result
},
/**
* Update an existing user
*/
update: async (id: string, data: Partial<User>): Promise<User> => {
const idErrors = validateId(id)
if (idErrors.length > 0) {
throw DBALError.validationError(
'Invalid user ID',
idErrors.map(error => ({ field: 'id', error }))
)
}
const validationErrors = validateUserUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid user update data',
validationErrors.map(error => ({ field: 'user', error }))
)
}
try {
return adapter.update('User', id, data) as Promise<User>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict(`Username or email already exists`)
}
throw error
}
},
/**
* Delete a user by ID
*/
delete: async (id: string): Promise<boolean> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid user ID',
validationErrors.map(error => ({ field: 'id', error }))
)
}
const result = await adapter.delete('User', id)
if (!result) {
throw DBALError.notFound(`User not found: ${id}`)
}
return result
},
/**
* List users with filtering and pagination
*/
list: async (options?: ListOptions): Promise<ListResult<User>> => {
return adapter.list('User', options) as Promise<ListResult<User>>
},
/**
* Batch create multiple users
*/
createMany: async (data: Array<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>): Promise<number> => {
if (!data || data.length === 0) {
return 0
}
const validationErrors = data.flatMap((item, index) =>
validateUserCreate(item).map(error => ({ field: `users[${index}]`, error }))
)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user batch', validationErrors)
}
try {
return adapter.createMany('User', data as Record<string, unknown>[])
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Username or email already exists')
}
throw error
}
},
/**
* Bulk update users matching a filter
*/
updateMany: async (filter: Record<string, unknown>, data: Partial<User>): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk update requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
if (!data || Object.keys(data).length === 0) {
throw DBALError.validationError('Bulk update requires data', [
{ field: 'data', error: 'Update data is required' },
])
}
const validationErrors = validateUserUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid user update data',
validationErrors.map(error => ({ field: 'user', error }))
)
}
try {
return adapter.updateMany('User', filter, data as Record<string, unknown>)
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Username or email already exists')
}
throw error
}
},
/**
* Bulk delete users matching a filter
*/
deleteMany: async (filter: Record<string, unknown>): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk delete requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
return adapter.deleteMany('User', filter)
},
})
export { createUser } from './user/create'
export { deleteUser } from './user/delete'
export { updateUser } from './user/update'
export {
assertValidUserCreate,
assertValidUserId,
assertValidUserUpdate,
} from './user/validation'

View File

@@ -0,0 +1,71 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { User } from '../../../../foundation/types'
import { DBALError } from '../../../../foundation/errors'
import { validateUserCreate, validateUserUpdate } from '../../../../foundation/validation'
export const createManyUsers = async (
adapter: DBALAdapter,
data: Array<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>,
): Promise<number> => {
if (!data || data.length === 0) {
return 0
}
const validationErrors = data.flatMap((item, index) =>
validateUserCreate(item).map(error => ({ field: `users[${index}]`, error })),
)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user batch', validationErrors)
}
try {
return adapter.createMany('User', data as Record<string, unknown>[])
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Username or email already exists')
}
throw error
}
}
export const updateManyUsers = async (
adapter: DBALAdapter,
filter: Record<string, unknown>,
data: Partial<User>,
): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk update requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
if (!data || Object.keys(data).length === 0) {
throw DBALError.validationError('Bulk update requires data', [
{ field: 'data', error: 'Update data is required' },
])
}
const validationErrors = validateUserUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user update data', validationErrors.map(error => ({ field: 'user', error })))
}
try {
return adapter.updateMany('User', filter, data as Record<string, unknown>)
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Username or email already exists')
}
throw error
}
}
export const deleteManyUsers = async (adapter: DBALAdapter, filter: Record<string, unknown>): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk delete requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
return adapter.deleteMany('User', filter)
}

View File

@@ -0,0 +1,20 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import { DBALError } from '../../../../foundation/errors'
import type { User } from '../../../../foundation/types'
import { assertValidUserCreate } from './validation'
export const createUser = async (
adapter: DBALAdapter,
data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>,
): Promise<User> => {
assertValidUserCreate(data)
try {
return adapter.create('User', data) as Promise<User>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('User with username or email already exists')
}
throw error
}
}

View File

@@ -0,0 +1,13 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import { DBALError } from '../../../../foundation/errors'
import { assertValidUserId } from './validation'
export const deleteUser = async (adapter: DBALAdapter, id: string): Promise<boolean> => {
assertValidUserId(id)
const result = await adapter.delete('User', id)
if (!result) {
throw DBALError.notFound(`User not found: ${id}`)
}
return result
}

View File

@@ -0,0 +1,29 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { User, ListOptions, ListResult } from '../../../../foundation/types'
import { createUser } from './create'
import { deleteUser } from './delete'
import { updateUser } from './update'
import { createManyUsers, deleteManyUsers, updateManyUsers } from './batch'
import { listUsers, readUser } from './reads'
export interface UserOperations {
create: (data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>) => Promise<User>
read: (id: string) => Promise<User | null>
update: (id: string, data: Partial<User>) => Promise<User>
delete: (id: string) => Promise<boolean>
list: (options?: ListOptions) => Promise<ListResult<User>>
createMany: (data: Array<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>) => Promise<number>
updateMany: (filter: Record<string, unknown>, data: Partial<User>) => Promise<number>
deleteMany: (filter: Record<string, unknown>) => Promise<number>
}
export const createUserOperations = (adapter: DBALAdapter): UserOperations => ({
create: data => createUser(adapter, data),
read: id => readUser(adapter, id),
update: (id, data) => updateUser(adapter, id, data),
delete: id => deleteUser(adapter, id),
list: options => listUsers(adapter, options),
createMany: data => createManyUsers(adapter, data),
updateMany: (filter, data) => updateManyUsers(adapter, filter, data),
deleteMany: filter => deleteManyUsers(adapter, filter),
})

View File

@@ -0,0 +1,21 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { User, ListOptions, ListResult } from '../../../../foundation/types'
import { DBALError } from '../../../../foundation/errors'
import { validateId } from '../../../../foundation/validation'
export const readUser = async (adapter: DBALAdapter, id: string): Promise<User | null> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user ID', validationErrors.map(error => ({ field: 'id', error })))
}
const result = await adapter.read('User', id) as User | null
if (!result) {
throw DBALError.notFound(`User not found: ${id}`)
}
return result
}
export const listUsers = (adapter: DBALAdapter, options?: ListOptions): Promise<ListResult<User>> => {
return adapter.list('User', options) as Promise<ListResult<User>>
}

View File

@@ -0,0 +1,22 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import { DBALError } from '../../../../foundation/errors'
import type { User } from '../../../../foundation/types'
import { assertValidUserId, assertValidUserUpdate } from './validation'
export const updateUser = async (
adapter: DBALAdapter,
id: string,
data: Partial<User>,
): Promise<User> => {
assertValidUserId(id)
assertValidUserUpdate(data)
try {
return adapter.update('User', id, data) as Promise<User>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Username or email already exists')
}
throw error
}
}

View File

@@ -0,0 +1,24 @@
import { DBALError } from '../../../../foundation/errors'
import type { User } from '../../../../foundation/types'
import { validateId, validateUserCreate, validateUserUpdate } from '../../../../foundation/validation'
export const assertValidUserId = (id: string): void => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user ID', validationErrors.map(error => ({ field: 'id', error })))
}
}
export const assertValidUserCreate = (data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): void => {
const validationErrors = validateUserCreate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user data', validationErrors.map(error => ({ field: 'user', error })))
}
}
export const assertValidUserUpdate = (data: Partial<User>): void => {
const validationErrors = validateUserUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user update data', validationErrors.map(error => ({ field: 'user', error })))
}
}

View File

@@ -1,185 +1 @@
/**
* @file package-operations.ts
* @description Package entity CRUD operations for DBAL client
*
* Single-responsibility module following the small-function-file pattern.
*/
import type { DBALAdapter } from '../../adapters/adapter'
import type { Package, ListOptions, ListResult } from '../types'
import { DBALError } from '../errors'
import { validatePackageCreate, validatePackageUpdate, validateId } from '../validation'
/**
* Create package operations object for the DBAL client
*/
export const createPackageOperations = (adapter: DBALAdapter) => ({
/**
* Create a new package
*/
create: async (data: Omit<Package, 'id' | 'createdAt' | 'updatedAt'>): Promise<Package> => {
const validationErrors = validatePackageCreate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid package data',
validationErrors.map(error => ({ field: 'package', error }))
)
}
try {
return adapter.create('Package', data) as Promise<Package>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict(`Package ${data.name}@${data.version} already exists`)
}
throw error
}
},
/**
* Read a package by ID
*/
read: async (id: string): Promise<Package | null> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid package ID',
validationErrors.map(error => ({ field: 'id', error }))
)
}
const result = await adapter.read('Package', id) as Package | null
if (!result) {
throw DBALError.notFound(`Package not found: ${id}`)
}
return result
},
/**
* Update an existing package
*/
update: async (id: string, data: Partial<Package>): Promise<Package> => {
const idErrors = validateId(id)
if (idErrors.length > 0) {
throw DBALError.validationError(
'Invalid package ID',
idErrors.map(error => ({ field: 'id', error }))
)
}
const validationErrors = validatePackageUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid package update data',
validationErrors.map(error => ({ field: 'package', error }))
)
}
try {
return adapter.update('Package', id, data) as Promise<Package>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
},
/**
* Delete a package by ID
*/
delete: async (id: string): Promise<boolean> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid package ID',
validationErrors.map(error => ({ field: 'id', error }))
)
}
const result = await adapter.delete('Package', id)
if (!result) {
throw DBALError.notFound(`Package not found: ${id}`)
}
return result
},
/**
* List packages with filtering and pagination
*/
list: async (options?: ListOptions): Promise<ListResult<Package>> => {
return adapter.list('Package', options) as Promise<ListResult<Package>>
},
/**
* Batch create multiple packages
*/
createMany: async (data: Array<Omit<Package, 'id' | 'createdAt' | 'updatedAt'>>): Promise<number> => {
if (!data || data.length === 0) {
return 0
}
const validationErrors = data.flatMap((item, index) =>
validatePackageCreate(item).map(error => ({ field: `packages[${index}]`, error }))
)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package batch', validationErrors)
}
try {
return adapter.createMany('Package', data as Record<string, unknown>[])
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
},
/**
* Bulk update packages matching a filter
*/
updateMany: async (filter: Record<string, unknown>, data: Partial<Package>): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk update requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
if (!data || Object.keys(data).length === 0) {
throw DBALError.validationError('Bulk update requires data', [
{ field: 'data', error: 'Update data is required' },
])
}
const validationErrors = validatePackageUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid package update data',
validationErrors.map(error => ({ field: 'package', error }))
)
}
try {
return adapter.updateMany('Package', filter, data as Record<string, unknown>)
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
},
/**
* Bulk delete packages matching a filter
*/
deleteMany: async (filter: Record<string, unknown>): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk delete requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
return adapter.deleteMany('Package', filter)
},
})
export * from './package'

View File

@@ -0,0 +1,71 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { Package } from '../../../../foundation/types'
import { DBALError } from '../../../../foundation/errors'
import { validatePackageCreate, validatePackageUpdate } from '../../../../foundation/validation'
export const createManyPackages = async (
adapter: DBALAdapter,
data: Array<Omit<Package, 'id' | 'createdAt' | 'updatedAt'>>,
): Promise<number> => {
if (!data || data.length === 0) {
return 0
}
const validationErrors = data.flatMap((item, index) =>
validatePackageCreate(item).map(error => ({ field: `packages[${index}]`, error })),
)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package batch', validationErrors)
}
try {
return adapter.createMany('Package', data as Record<string, unknown>[])
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
}
export const updateManyPackages = async (
adapter: DBALAdapter,
filter: Record<string, unknown>,
data: Partial<Package>,
): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk update requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
if (!data || Object.keys(data).length === 0) {
throw DBALError.validationError('Bulk update requires data', [
{ field: 'data', error: 'Update data is required' },
])
}
const validationErrors = validatePackageUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package update data', validationErrors.map(error => ({ field: 'package', error })))
}
try {
return adapter.updateMany('Package', filter, data as Record<string, unknown>)
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
}
export const deleteManyPackages = async (adapter: DBALAdapter, filter: Record<string, unknown>): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk delete requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
return adapter.deleteMany('Package', filter)
}

View File

@@ -0,0 +1,40 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { Package, ListOptions, ListResult } from '../../../../foundation/types'
import { createManyPackages, deleteManyPackages, updateManyPackages } from './batch'
import { createPackage, deletePackage, updatePackage } from './mutations'
import { publishPackage } from './publish'
import { listPackages, readPackage } from './reads'
import { unpublishPackage } from './unpublish'
import { validatePackage } from './validate'
export interface PackageOperations {
validate: (data: Partial<Package>) => string[]
publish: (data: Omit<Package, 'id' | 'createdAt' | 'updatedAt'>) => Promise<Package>
unpublish: (id: string) => Promise<boolean>
create: (data: Omit<Package, 'id' | 'createdAt' | 'updatedAt'>) => Promise<Package>
read: (id: string) => Promise<Package | null>
update: (id: string, data: Partial<Package>) => Promise<Package>
delete: (id: string) => Promise<boolean>
list: (options?: ListOptions) => Promise<ListResult<Package>>
createMany: (data: Array<Omit<Package, 'id' | 'createdAt' | 'updatedAt'>>) => Promise<number>
updateMany: (filter: Record<string, unknown>, data: Partial<Package>) => Promise<number>
deleteMany: (filter: Record<string, unknown>) => Promise<number>
}
export const createPackageOperations = (adapter: DBALAdapter): PackageOperations => ({
validate: data => validatePackage(data),
publish: data => publishPackage(adapter, data),
unpublish: id => unpublishPackage(adapter, id),
create: data => createPackage(adapter, data),
read: id => readPackage(adapter, id),
update: (id, data) => updatePackage(adapter, id, data),
delete: id => deletePackage(adapter, id),
list: options => listPackages(adapter, options),
createMany: data => createManyPackages(adapter, data),
updateMany: (filter, data) => updateManyPackages(adapter, filter, data),
deleteMany: filter => deleteManyPackages(adapter, filter),
})
export { publishPackage } from './publish'
export { unpublishPackage } from './unpublish'
export { validatePackage } from './validate'

View File

@@ -0,0 +1,61 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { Package } from '../../../../foundation/types'
import { DBALError } from '../../../../foundation/errors'
import { validatePackageCreate, validatePackageUpdate, validateId } from '../../../../foundation/validation'
export const createPackage = async (
adapter: DBALAdapter,
data: Omit<Package, 'id' | 'createdAt' | 'updatedAt'>,
): Promise<Package> => {
const validationErrors = validatePackageCreate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package data', validationErrors.map(error => ({ field: 'package', error })))
}
try {
return adapter.create('Package', data) as Promise<Package>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict(`Package ${data.name}@${data.version} already exists`)
}
throw error
}
}
export const updatePackage = async (
adapter: DBALAdapter,
id: string,
data: Partial<Package>,
): Promise<Package> => {
const idErrors = validateId(id)
if (idErrors.length > 0) {
throw DBALError.validationError('Invalid package ID', idErrors.map(error => ({ field: 'id', error })))
}
const validationErrors = validatePackageUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package update data', validationErrors.map(error => ({ field: 'package', error })))
}
try {
return adapter.update('Package', id, data) as Promise<Package>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
}
export const deletePackage = async (adapter: DBALAdapter, id: string): Promise<boolean> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package ID', validationErrors.map(error => ({ field: 'id', error })))
}
const result = await adapter.delete('Package', id)
if (!result) {
throw DBALError.notFound(`Package not found: ${id}`)
}
return result
}

View File

@@ -0,0 +1,10 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { Package } from '../../../../foundation/types'
import { createPackage } from './mutations'
export const publishPackage = (
adapter: DBALAdapter,
data: Omit<Package, 'id' | 'createdAt' | 'updatedAt'>,
): Promise<Package> => {
return createPackage(adapter, data)
}

View File

@@ -0,0 +1,21 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { Package, ListOptions, ListResult } from '../../../../foundation/types'
import { DBALError } from '../../../../foundation/errors'
import { validateId } from '../../../../foundation/validation'
export const readPackage = async (adapter: DBALAdapter, id: string): Promise<Package | null> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package ID', validationErrors.map(error => ({ field: 'id', error })))
}
const result = await adapter.read('Package', id) as Package | null
if (!result) {
throw DBALError.notFound(`Package not found: ${id}`)
}
return result
}
export const listPackages = (adapter: DBALAdapter, options?: ListOptions): Promise<ListResult<Package>> => {
return adapter.list('Package', options) as Promise<ListResult<Package>>
}

View File

@@ -0,0 +1,6 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import { deletePackage } from './mutations'
export const unpublishPackage = (adapter: DBALAdapter, id: string): Promise<boolean> => {
return deletePackage(adapter, id)
}

View File

@@ -0,0 +1,6 @@
import type { Package } from '../../../../foundation/types'
import { validatePackageCreate } from '../../../../foundation/validation'
export const validatePackage = (data: Partial<Package>): string[] => {
return validatePackageCreate(data)
}

View File

@@ -1,216 +1 @@
export interface User {
id: string
username: string
email: string
role: 'user' | 'admin' | 'god' | 'supergod'
createdAt: Date
updatedAt: Date
}
export interface CreateUserInput {
username: string
email: string
role?: User['role']
}
export interface UpdateUserInput {
username?: string
email?: string
role?: User['role']
}
export interface Credential {
id: string
username: string
passwordHash: string
firstLogin: boolean
createdAt: Date
updatedAt: Date
}
export interface Session {
id: string
userId: string
token: string
expiresAt: Date
createdAt: Date
lastActivity: Date
}
export interface CreateSessionInput {
userId: string
token: string
expiresAt: Date
}
export interface UpdateSessionInput {
userId?: string
token?: string
expiresAt?: Date
lastActivity?: Date
}
export interface PageView {
id: string
slug: string
title: string
description?: string
level: number
layout: Record<string, unknown>
isActive: boolean
createdAt: Date
updatedAt: Date
}
export interface CreatePageInput {
slug: string
title: string
description?: string
level: number
layout: Record<string, unknown>
isActive?: boolean
}
export interface UpdatePageInput {
slug?: string
title?: string
description?: string
level?: number
layout?: Record<string, unknown>
isActive?: boolean
}
export interface ComponentHierarchy {
id: string
pageId: string
parentId?: string
componentType: string
order: number
props: Record<string, unknown>
createdAt: Date
updatedAt: Date
}
export interface Workflow {
id: string
name: string
description?: string
trigger: 'manual' | 'schedule' | 'event' | 'webhook'
triggerConfig: Record<string, unknown>
steps: Record<string, unknown>
isActive: boolean
createdBy: string
createdAt: Date
updatedAt: Date
}
export interface CreateWorkflowInput {
name: string
description?: string
trigger: Workflow['trigger']
triggerConfig: Record<string, unknown>
steps: Record<string, unknown>
isActive?: boolean
createdBy: string
}
export interface UpdateWorkflowInput {
name?: string
description?: string
trigger?: Workflow['trigger']
triggerConfig?: Record<string, unknown>
steps?: Record<string, unknown>
isActive?: boolean
createdBy?: string
}
export interface LuaScript {
id: string
name: string
description?: string
code: string
isSandboxed: boolean
allowedGlobals: string[]
timeoutMs: number
createdBy: string
createdAt: Date
updatedAt: Date
}
export interface CreateLuaScriptInput {
name: string
description?: string
code: string
isSandboxed?: boolean
allowedGlobals: string[]
timeoutMs?: number
createdBy: string
}
export interface UpdateLuaScriptInput {
name?: string
description?: string
code?: string
isSandboxed?: boolean
allowedGlobals?: string[]
timeoutMs?: number
createdBy?: string
}
export interface Package {
id: string
name: string
version: string
description?: string
author: string
manifest: Record<string, unknown>
isInstalled: boolean
installedAt?: Date
installedBy?: string
createdAt: Date
updatedAt: Date
}
export interface CreatePackageInput {
name: string
version: string
description?: string
author: string
manifest: Record<string, unknown>
isInstalled?: boolean
installedAt?: Date
installedBy?: string
}
export interface UpdatePackageInput {
name?: string
version?: string
description?: string
author?: string
manifest?: Record<string, unknown>
isInstalled?: boolean
installedAt?: Date
installedBy?: string
}
export interface ListOptions {
filter?: Record<string, unknown>
sort?: Record<string, 'asc' | 'desc'>
page?: number
limit?: number
}
export interface ListResult<T> {
data: T[]
total: number
page: number
limit: number
hasMore: boolean
}
export interface ResultError {
code: string
message: string
}
export type Result<T> = { success: true; data: T } | { success: false; error: ResultError }
export * from './types'

View File

@@ -0,0 +1,30 @@
export interface Credential {
id: string
username: string
passwordHash: string
firstLogin: boolean
createdAt: Date
updatedAt: Date
}
export interface Session {
id: string
userId: string
token: string
expiresAt: Date
createdAt: Date
lastActivity: Date
}
export interface CreateSessionInput {
userId: string
token: string
expiresAt: Date
}
export interface UpdateSessionInput {
userId?: string
token?: string
expiresAt?: Date
lastActivity?: Date
}

View File

@@ -0,0 +1,65 @@
export interface Workflow {
id: string
name: string
description?: string
trigger: 'manual' | 'schedule' | 'event' | 'webhook'
triggerConfig: Record<string, unknown>
steps: Record<string, unknown>
isActive: boolean
createdBy: string
createdAt: Date
updatedAt: Date
}
export interface CreateWorkflowInput {
name: string
description?: string
trigger: Workflow['trigger']
triggerConfig: Record<string, unknown>
steps: Record<string, unknown>
isActive?: boolean
createdBy: string
}
export interface UpdateWorkflowInput {
name?: string
description?: string
trigger?: Workflow['trigger']
triggerConfig?: Record<string, unknown>
steps?: Record<string, unknown>
isActive?: boolean
createdBy?: string
}
export interface LuaScript {
id: string
name: string
description?: string
code: string
isSandboxed: boolean
allowedGlobals: string[]
timeoutMs: number
createdBy: string
createdAt: Date
updatedAt: Date
}
export interface CreateLuaScriptInput {
name: string
description?: string
code: string
isSandboxed?: boolean
allowedGlobals: string[]
timeoutMs?: number
createdBy: string
}
export interface UpdateLuaScriptInput {
name?: string
description?: string
code?: string
isSandboxed?: boolean
allowedGlobals?: string[]
timeoutMs?: number
createdBy?: string
}

View File

@@ -0,0 +1,40 @@
export interface PageView {
id: string
slug: string
title: string
description?: string
level: number
layout: Record<string, unknown>
isActive: boolean
createdAt: Date
updatedAt: Date
}
export interface CreatePageInput {
slug: string
title: string
description?: string
level: number
layout: Record<string, unknown>
isActive?: boolean
}
export interface UpdatePageInput {
slug?: string
title?: string
description?: string
level?: number
layout?: Record<string, unknown>
isActive?: boolean
}
export interface ComponentHierarchy {
id: string
pageId: string
parentId?: string
componentType: string
order: number
props: Record<string, unknown>
createdAt: Date
updatedAt: Date
}

View File

@@ -0,0 +1,19 @@
export type EntityId = string
export interface BaseEntity {
id: EntityId
createdAt: Date
updatedAt: Date
}
export interface SoftDeletableEntity extends BaseEntity {
deletedAt?: Date
}
export interface TenantScopedEntity extends BaseEntity {
tenantId: string
}
export interface EntityMetadata {
metadata?: Record<string, unknown>
}

View File

@@ -0,0 +1,13 @@
import type { OperationContext } from './operations'
export interface DomainEvent<TPayload = Record<string, unknown>> {
id: string
name: string
occurredAt: Date
payload: TPayload
context?: OperationContext
}
export interface EventHandler<TPayload = Record<string, unknown>> {
(event: DomainEvent<TPayload>): void | Promise<void>
}

View File

@@ -0,0 +1,9 @@
export * from './users'
export * from './auth'
export * from './content'
export * from './automation'
export * from './packages'
export * from './shared'
export * from './entities'
export * from './operations'
export * from './events'

View File

@@ -0,0 +1,19 @@
export interface OperationContext {
tenantId?: string
userId?: string
correlationId?: string
traceId?: string
metadata?: Record<string, unknown>
}
export interface OperationOptions {
timeoutMs?: number
retryCount?: number
dryRun?: boolean
}
export interface OperationAuditTrail {
performedAt: Date
performedBy?: string
context?: OperationContext
}

View File

@@ -0,0 +1,35 @@
export interface Package {
id: string
name: string
version: string
description?: string
author: string
manifest: Record<string, unknown>
isInstalled: boolean
installedAt?: Date
installedBy?: string
createdAt: Date
updatedAt: Date
}
export interface CreatePackageInput {
name: string
version: string
description?: string
author: string
manifest: Record<string, unknown>
isInstalled?: boolean
installedAt?: Date
installedBy?: string
}
export interface UpdatePackageInput {
name?: string
version?: string
description?: string
author?: string
manifest?: Record<string, unknown>
isInstalled?: boolean
installedAt?: Date
installedBy?: string
}

View File

@@ -0,0 +1,21 @@
export interface ListOptions {
filter?: Record<string, unknown>
sort?: Record<string, 'asc' | 'desc'>
page?: number
limit?: number
}
export interface ListResult<T> {
data: T[]
total: number
page: number
limit: number
hasMore: boolean
}
export interface ResultError {
code: string
message: string
}
export type Result<T> = { success: true; data: T } | { success: false; error: ResultError }

View File

@@ -0,0 +1,20 @@
export interface User {
id: string
username: string
email: string
role: 'user' | 'admin' | 'god' | 'supergod'
createdAt: Date
updatedAt: Date
}
export interface CreateUserInput {
username: string
email: string
role?: User['role']
}
export interface UpdateUserInput {
username?: string
email?: string
role?: User['role']
}

View File

@@ -1,342 +1,20 @@
import { execSync, spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import path from 'path'
import { runCppBuildAssistant } from './cpp-build-assistant/runner'
const COLORS = {
reset: '\x1b[0m',
bright: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
} as const;
interface ExecResult {
success: boolean;
output?: string;
error?: string;
}
interface ExecOptions {
cwd?: string;
silent?: boolean;
args?: string[];
}
const log = {
info: (msg: string) => console.log(`${COLORS.blue}${COLORS.reset} ${msg}`),
success: (msg: string) => console.log(`${COLORS.green}${COLORS.reset} ${msg}`),
warn: (msg: string) => console.log(`${COLORS.yellow}${COLORS.reset} ${msg}`),
error: (msg: string) => console.log(`${COLORS.red}${COLORS.reset} ${msg}`),
section: (msg: string) => console.log(`\n${COLORS.bright}${COLORS.cyan}${msg}${COLORS.reset}\n`),
};
export class CppBuildAssistant {
private projectRoot: string;
private cppDir: string;
private buildDir: string;
constructor(projectRoot?: string) {
this.projectRoot = projectRoot || path.join(__dirname, '..');
this.cppDir = path.join(this.projectRoot, 'cpp');
this.buildDir = path.join(this.cppDir, 'build');
}
private exec(command: string, options: ExecOptions = {}): ExecResult {
try {
const result = execSync(command, {
cwd: options.cwd || this.cppDir,
encoding: 'utf-8',
stdio: options.silent ? 'pipe' : 'inherit',
});
return { success: true, output: result as string };
} catch (error: any) {
return { success: false, error: error.message, output: error.stdout };
}
}
private checkCommand(command: string, name: string): boolean {
try {
execSync(`${command} --version`, { stdio: 'pipe' });
log.success(`${name} is installed`);
return true;
} catch {
log.error(`${name} is NOT installed`);
return false;
}
}
checkDependencies(): boolean {
log.section('Checking Dependencies');
const deps = [
{ cmd: 'cmake', name: 'CMake' },
{ cmd: 'conan', name: 'Conan' },
{ cmd: 'ninja', name: 'Ninja' },
{ cmd: 'g++', name: 'GCC' },
];
const results = deps.map(({ cmd, name }) => ({
name,
installed: this.checkCommand(cmd, name),
}));
const allInstalled = results.every((r) => r.installed);
if (!allInstalled) {
log.warn('\nSome dependencies are missing. Install them:');
if (os.platform() === 'darwin') {
log.info(' brew install cmake conan ninja gcc');
} else if (os.platform() === 'linux') {
log.info(' sudo apt-get install cmake ninja-build g++');
log.info(' pip install conan');
} else if (os.platform() === 'win32') {
log.info(' choco install cmake conan ninja');
}
}
return allInstalled;
}
createConanfile(): boolean {
log.section('Checking Conanfile');
const conanfilePath = path.join(this.cppDir, 'conanfile.txt');
if (fs.existsSync(conanfilePath)) {
log.success('conanfile.txt exists');
return true;
}
log.info('Creating conanfile.txt...');
const conanfileContent = `[requires]
sqlite3/3.45.0
fmt/10.2.1
spdlog/1.13.0
nlohmann_json/3.11.3
[generators]
CMakeDeps
CMakeToolchain
[options]
sqlite3:shared=False
[layout]
cmake_layout
`;
fs.writeFileSync(conanfilePath, conanfileContent);
log.success('Created conanfile.txt');
return true;
}
installConanDeps(): boolean {
log.section('Installing Conan Dependencies');
const conanfilePath = path.join(this.cppDir, 'conanfile.txt');
if (!fs.existsSync(conanfilePath)) {
log.error('conanfile.txt not found');
return false;
}
log.info('Running conan install...');
const buildType = process.env.CMAKE_BUILD_TYPE || 'Release';
const result = this.exec(
`conan install . --output-folder=build --build=missing -s build_type=${buildType}`
);
if (!result.success) {
log.error('Conan install failed');
return false;
}
log.success('Conan dependencies installed');
return true;
}
configureCMake(buildType: 'Debug' | 'Release' = 'Release'): boolean {
log.section('Configuring CMake with Ninja');
if (!fs.existsSync(this.buildDir)) {
fs.mkdirSync(this.buildDir, { recursive: true });
}
log.info(`Build type: ${buildType}`);
const toolchainPath = path.join(this.buildDir, 'conan_toolchain.cmake');
const cmakeArgs = [
'-G Ninja',
`-DCMAKE_BUILD_TYPE=${buildType}`,
'-DCMAKE_EXPORT_COMPILE_COMMANDS=ON',
];
if (fs.existsSync(toolchainPath)) {
cmakeArgs.push(`-DCMAKE_TOOLCHAIN_FILE=${toolchainPath}`);
log.info('Using Conan toolchain');
}
const result = this.exec(`cmake -B build ${cmakeArgs.join(' ')} .`);
if (!result.success) {
log.error('CMake configuration failed');
return false;
}
log.success('CMake configured successfully');
return true;
}
build(target = 'all', jobs = os.cpus().length): boolean {
log.section('Building with Ninja');
log.info(`Building target: ${target}`);
log.info(`Using ${jobs} parallel jobs`);
const result = this.exec(`cmake --build build --target ${target} -j ${jobs}`);
if (!result.success) {
log.error('Build failed');
return false;
}
log.success('Build completed successfully');
return true;
}
test(): boolean {
log.section('Running Tests');
const result = this.exec('ctest --test-dir build --output-on-failure');
if (!result.success) {
log.error('Tests failed');
return false;
}
log.success('All tests passed');
return true;
}
clean(): boolean {
log.section('Cleaning Build Artifacts');
if (fs.existsSync(this.buildDir)) {
fs.rmSync(this.buildDir, { recursive: true, force: true });
log.success('Build directory removed');
} else {
log.info('Build directory does not exist');
}
return true;
}
async run(args: string[]): Promise<boolean> {
const command = args[0] || 'help';
const options = args.slice(1);
const buildType = options.includes('--debug') ? 'Debug' : 'Release';
const jobsArg = options.find((o) => o.startsWith('--jobs='));
const jobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : os.cpus().length;
switch (command) {
case 'check':
return this.checkDependencies();
case 'init':
return this.createConanfile();
case 'install':
if (!this.checkDependencies()) return false;
return this.installConanDeps();
case 'configure':
if (!this.checkDependencies()) return false;
return this.configureCMake(buildType as 'Debug' | 'Release');
case 'build':
if (!this.checkDependencies()) return false;
const target = options.find((o) => !o.startsWith('--')) || 'all';
return this.build(target, jobs);
case 'test':
return this.test();
case 'clean':
return this.clean();
case 'rebuild':
this.clean();
if (!this.checkDependencies()) return false;
if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false;
return this.build('all', jobs);
case 'full':
log.section('Full Build Workflow');
if (!this.checkDependencies()) return false;
if (!this.createConanfile()) return false;
if (!this.installConanDeps()) return false;
if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false;
return this.build('all', jobs);
case 'help':
default:
this.showHelp();
return true;
}
}
private showHelp(): void {
console.log(`
${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper
${COLORS.cyan}USAGE:${COLORS.reset}
npm run cpp:build [command] [options]
${COLORS.cyan}COMMANDS:${COLORS.reset}
${COLORS.green}check${COLORS.reset} Check if all dependencies are installed
${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing)
${COLORS.green}install${COLORS.reset} Install Conan dependencies
${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator
${COLORS.green}build${COLORS.reset} [target] Build the project (default: all)
${COLORS.green}test${COLORS.reset} Run tests with CTest
${COLORS.green}clean${COLORS.reset} Remove build artifacts
${COLORS.green}rebuild${COLORS.reset} Clean and rebuild
${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build
${COLORS.green}help${COLORS.reset} Show this help message
${COLORS.cyan}OPTIONS:${COLORS.reset}
--debug Use Debug build type
--release Use Release build type (default)
--jobs=N Number of parallel build jobs (default: CPU count)
${COLORS.cyan}EXAMPLES:${COLORS.reset}
npm run cpp:build check
npm run cpp:build full
npm run cpp:build build dbal_daemon
npm run cpp:build build -- --debug
npm run cpp:build test
`);
}
}
export { CppBuildAssistant, createAssistant } from './cpp-build-assistant'
export { createCppBuildAssistantConfig } from './cpp-build-assistant/config'
export { runCppBuildAssistant } from './cpp-build-assistant/runner'
if (require.main === module) {
const assistant = new CppBuildAssistant(path.join(__dirname, '..'));
const args = process.argv.slice(2);
const args = process.argv.slice(2)
const projectRoot = path.join(__dirname, '..')
assistant
.run(args)
.then((success) => {
process.exit(success ? 0 : 1);
runCppBuildAssistant(args, projectRoot)
.then(success => {
process.exit(success ? 0 : 1)
})
.catch(error => {
console.error(error?.message || error)
process.exit(1)
})
.catch((error) => {
log.error(error.message || error);
process.exit(1);
});
}

View File

@@ -0,0 +1,125 @@
import os from 'os'
import { BuildType } from './config'
import { COLORS, log } from './logging'
import { CppBuildAssistant } from './index'
export type CliCommand =
| 'check'
| 'init'
| 'install'
| 'configure'
| 'build'
| 'test'
| 'clean'
| 'rebuild'
| 'full'
| 'help'
export interface ParsedCliArgs {
command: CliCommand
buildType: BuildType
jobs: number
target?: string
options: string[]
}
const parseBuildType = (options: string[]): BuildType => (options.includes('--debug') ? 'Debug' : 'Release')
const parseJobs = (options: string[]): number => {
const jobsArg = options.find(option => option.startsWith('--jobs='))
const parsedJobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : Number.NaN
return Number.isNaN(parsedJobs) ? os.cpus().length : parsedJobs
}
const parseTarget = (command: CliCommand, options: string[]): string | undefined => {
if (command !== 'build') return undefined
return options.find(option => !option.startsWith('--')) || 'all'
}
export const parseCliArgs = (args: string[]): ParsedCliArgs => {
const command = (args[0] as CliCommand | undefined) || 'help'
const options = args.slice(1)
return {
command,
buildType: parseBuildType(options),
jobs: parseJobs(options),
target: parseTarget(command, options),
options,
}
}
export const showHelp = (): void => {
console.log(`
${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper
${COLORS.cyan}USAGE:${COLORS.reset}
npm run cpp:build [command] [options]
${COLORS.cyan}COMMANDS:${COLORS.reset}
${COLORS.green}check${COLORS.reset} Check if all dependencies are installed
${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing)
${COLORS.green}install${COLORS.reset} Install Conan dependencies
${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator
${COLORS.green}build${COLORS.reset} [target] Build the project (default: all)
${COLORS.green}test${COLORS.reset} Run tests with CTest
${COLORS.green}clean${COLORS.reset} Remove build artifacts
${COLORS.green}rebuild${COLORS.reset} Clean and rebuild
${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build
${COLORS.green}help${COLORS.reset} Show this help message
${COLORS.cyan}OPTIONS:${COLORS.reset}
--debug Use Debug build type
--release Use Release build type (default)
--jobs=N Number of parallel build jobs (default: CPU count)
${COLORS.cyan}EXAMPLES:${COLORS.reset}
npm run cpp:build check
npm run cpp:build full
npm run cpp:build build dbal_daemon
npm run cpp:build build -- --debug
npm run cpp:build test
`)
}
export const runCli = async (args: string[], assistant: CppBuildAssistant): Promise<boolean> => {
const parsed = parseCliArgs(args)
switch (parsed.command) {
case 'check':
return assistant.checkDependencies()
case 'init':
return assistant.createConanfile()
case 'install':
if (!assistant.checkDependencies()) return false
return assistant.installConanDeps()
case 'configure':
if (!assistant.checkDependencies()) return false
return assistant.configureCMake(parsed.buildType)
case 'build':
if (!assistant.checkDependencies()) return false
return assistant.build(parsed.target, parsed.jobs)
case 'test':
return assistant.test()
case 'clean':
return assistant.clean()
case 'rebuild':
assistant.clean()
if (!assistant.checkDependencies()) return false
if (!assistant.configureCMake(parsed.buildType)) return false
return assistant.build('all', parsed.jobs)
case 'full':
log.section('Full Build Workflow')
if (!assistant.checkDependencies()) return false
if (!assistant.createConanfile()) return false
if (!assistant.installConanDeps()) return false
if (!assistant.configureCMake(parsed.buildType)) return false
return assistant.build('all', parsed.jobs)
case 'help':
default:
showHelp()
return true
}
}

View File

@@ -0,0 +1,20 @@
import path from 'path'
export type BuildType = 'Debug' | 'Release'
export interface CppBuildAssistantConfig {
projectRoot: string
cppDir: string
buildDir: string
}
export const createCppBuildAssistantConfig = (projectRoot?: string): CppBuildAssistantConfig => {
const resolvedProjectRoot = projectRoot || path.join(__dirname, '..')
const cppDir = path.join(resolvedProjectRoot, 'cpp')
return {
projectRoot: resolvedProjectRoot,
cppDir,
buildDir: path.join(cppDir, 'build'),
}
}

View File

@@ -0,0 +1,47 @@
import { execSync } from 'child_process'
import os from 'os'
import { log } from './logging'
export const checkCommand = (command: string, name: string): boolean => {
try {
execSync(`${command} --version`, { stdio: 'pipe' })
log.success(`${name} is installed`)
return true
} catch {
log.error(`${name} is NOT installed`)
return false
}
}
export const checkDependencies = (): boolean => {
log.section('Checking Dependencies')
const deps = [
{ cmd: 'cmake', name: 'CMake' },
{ cmd: 'conan', name: 'Conan' },
{ cmd: 'ninja', name: 'Ninja' },
{ cmd: 'g++', name: 'GCC' },
]
const results = deps.map(({ cmd, name }) => ({
name,
installed: checkCommand(cmd, name),
}))
const allInstalled = results.every(result => result.installed)
if (!allInstalled) {
log.warn('\nSome dependencies are missing. Install them:')
if (os.platform() === 'darwin') {
log.info(' brew install cmake conan ninja gcc')
} else if (os.platform() === 'linux') {
log.info(' sudo apt-get install cmake ninja-build g++')
log.info(' pip install conan')
} else if (os.platform() === 'win32') {
log.info(' choco install cmake conan ninja')
}
}
return allInstalled
}

View File

@@ -0,0 +1,61 @@
import os from 'os'
import path from 'path'
import { CppBuildAssistantConfig, BuildType, createCppBuildAssistantConfig } from './config'
import { COLORS, log } from './logging'
import { checkDependencies } from './dependencies'
import { cleanBuild, configureCMake, ensureConanFile, execCommand, installConanDeps, buildTarget, runTests } from './workflow'
export class CppBuildAssistant {
private config: CppBuildAssistantConfig
constructor(config?: CppBuildAssistantConfig) {
this.config = config || createCppBuildAssistantConfig()
}
get projectRoot(): string {
return this.config.projectRoot
}
get cppDir(): string {
return this.config.cppDir
}
get buildDir(): string {
return this.config.buildDir
}
checkDependencies(): boolean {
return checkDependencies()
}
createConanfile(): boolean {
return ensureConanFile(this.cppDir)
}
installConanDeps(): boolean {
return installConanDeps(this.cppDir, execCommand)
}
configureCMake(buildType: BuildType = 'Release'): boolean {
return configureCMake(this.cppDir, buildType, execCommand)
}
build(target = 'all', jobs = os.cpus().length): boolean {
return buildTarget(this.cppDir, target, jobs, execCommand)
}
test(): boolean {
return runTests(this.cppDir, execCommand)
}
clean(): boolean {
return cleanBuild(this.buildDir)
}
}
export const createAssistant = (projectRoot?: string): CppBuildAssistant => {
const config = createCppBuildAssistantConfig(projectRoot || path.join(__dirname, '..'))
return new CppBuildAssistant(config)
}
export { BuildType, CppBuildAssistantConfig, COLORS, log }

View File

@@ -0,0 +1,18 @@
export const COLORS = {
reset: '\x1b[0m',
bright: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
} as const
export const log = {
info: (msg: string) => console.log(`${COLORS.blue}${COLORS.reset} ${msg}`),
success: (msg: string) => console.log(`${COLORS.green}${COLORS.reset} ${msg}`),
warn: (msg: string) => console.log(`${COLORS.yellow}${COLORS.reset} ${msg}`),
error: (msg: string) => console.log(`${COLORS.red}${COLORS.reset} ${msg}`),
section: (msg: string) => console.log(`\n${COLORS.bright}${COLORS.cyan}${msg}${COLORS.reset}\n`),
}

View File

@@ -0,0 +1,10 @@
import { CppBuildAssistant } from './index'
import { createCppBuildAssistantConfig } from './config'
import { runCli } from './cli'
export const runCppBuildAssistant = async (args: string[], projectRoot?: string): Promise<boolean> => {
const config = createCppBuildAssistantConfig(projectRoot)
const assistant = new CppBuildAssistant(config)
return runCli(args, assistant)
}

View File

@@ -0,0 +1,11 @@
export interface ExecResult {
success: boolean
output?: string
error?: string
}
export interface ExecOptions {
cwd?: string
silent?: boolean
args?: string[]
}

View File

@@ -0,0 +1,153 @@
import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import os from 'os'
import { log } from './logging'
import type { ExecResult, ExecOptions } from './types'
export const execCommand = (command: string, cppDir: string, options: ExecOptions = {}): ExecResult => {
try {
const result = execSync(command, {
cwd: options.cwd || cppDir,
encoding: 'utf-8',
stdio: options.silent ? 'pipe' : 'inherit',
})
return { success: true, output: result as string }
} catch (error: any) {
return { success: false, error: error.message, output: error.stdout }
}
}
export const ensureConanFile = (cppDir: string): boolean => {
log.section('Checking Conanfile')
const conanfilePath = path.join(cppDir, 'conanfile.txt')
if (fs.existsSync(conanfilePath)) {
log.success('conanfile.txt exists')
return true
}
log.info('Creating conanfile.txt...')
const conanfileContent = `[requires]
sqlite3/3.45.0
fmt/10.2.1
spdlog/1.13.0
nlohmann_json/3.11.3
[generators]
CMakeDeps
CMakeToolchain
[options]
sqlite3:shared=False
[layout]
cmake_layout
`
fs.writeFileSync(conanfilePath, conanfileContent)
log.success('Created conanfile.txt')
return true
}
export const installConanDeps = (cppDir: string, execFn: typeof execCommand): boolean => {
log.section('Installing Conan Dependencies')
const conanfilePath = path.join(cppDir, 'conanfile.txt')
if (!fs.existsSync(conanfilePath)) {
log.error('conanfile.txt not found')
return false
}
log.info('Running conan install...')
const buildType = process.env.CMAKE_BUILD_TYPE || 'Release'
const result = execFn(`conan install . --output-folder=build --build=missing -s build_type=${buildType}`, cppDir)
if (!result.success) {
log.error('Conan install failed')
return false
}
log.success('Conan dependencies installed')
return true
}
export const configureCMake = (cppDir: string, buildType: 'Debug' | 'Release', execFn: typeof execCommand): boolean => {
log.section('Configuring CMake with Ninja')
const buildDir = path.join(cppDir, 'build')
if (!fs.existsSync(buildDir)) {
fs.mkdirSync(buildDir, { recursive: true })
}
log.info(`Build type: ${buildType}`)
const toolchainPath = path.join(buildDir, 'conan_toolchain.cmake')
const cmakeArgs = [
'-G Ninja',
`-DCMAKE_BUILD_TYPE=${buildType}`,
'-DCMAKE_EXPORT_COMPILE_COMMANDS=ON',
]
if (fs.existsSync(toolchainPath)) {
cmakeArgs.push(`-DCMAKE_TOOLCHAIN_FILE=${toolchainPath}`)
log.info('Using Conan toolchain')
}
const result = execFn(`cmake -B build ${cmakeArgs.join(' ')} .`, cppDir)
if (!result.success) {
log.error('CMake configuration failed')
return false
}
log.success('CMake configured successfully')
return true
}
export const buildTarget = (cppDir: string, target = 'all', jobs = os.cpus().length, execFn: typeof execCommand): boolean => {
log.section('Building with Ninja')
log.info(`Building target: ${target}`)
log.info(`Using ${jobs} parallel jobs`)
const result = execFn(`cmake --build build --target ${target} -j ${jobs}`, cppDir)
if (!result.success) {
log.error('Build failed')
return false
}
log.success('Build completed successfully')
return true
}
export const runTests = (cppDir: string, execFn: typeof execCommand): boolean => {
log.section('Running Tests')
const result = execFn('ctest --test-dir build --output-on-failure', cppDir)
if (!result.success) {
log.error('Tests failed')
return false
}
log.success('All tests passed')
return true
}
export const cleanBuild = (buildDir: string): boolean => {
log.section('Cleaning Build Artifacts')
if (fs.existsSync(buildDir)) {
fs.rmSync(buildDir, { recursive: true, force: true })
log.success('Build directory removed')
} else {
log.info('Build directory does not exist')
}
return true
}

View File

@@ -1,6 +1,5 @@
'use client'
import type { CodegenManifest } from '@/lib/codegen/codegen-types'
import { useMemo, useState, type ChangeEvent } from 'react'
import {
@@ -16,6 +15,10 @@ import {
Typography,
} from '@mui/material'
import Header from './components/Header'
import Sidebar from './components/Sidebar'
import { useCodegenData, type CodegenRequest } from './hooks/useCodegenData'
const runtimeOptions = [
{ value: 'web', label: 'Next.js web' },
{ value: 'cli', label: 'Command line' },
@@ -24,7 +27,7 @@ const runtimeOptions = [
{ value: 'server', label: 'Server service' },
]
const initialFormState = {
const initialFormState: CodegenRequest = {
projectName: 'nebula-launch',
packageId: 'codegen_studio',
runtime: 'web',
@@ -32,51 +35,11 @@ const initialFormState = {
brief: 'Modern web interface with CLI companions',
}
type FormState = (typeof initialFormState)
type FetchStatus = 'idle' | 'loading' | 'success'
const createFilename = (header: string | null, fallback: string) => {
const match = header?.match(/filename="?([^"]+)"?/) ?? null
return match ? match[1] : fallback
}
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
const fetchZip = async (values: FormState) => {
const response = await fetch('/api/codegen/studio', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
})
if (!response.ok) {
throw new Error('Codegen Studio service returned an error')
}
const blob = await response.blob()
const filename = createFilename(response.headers.get('content-disposition'), `${values.projectName}.zip`)
downloadBlob(blob, filename)
const manifestHeader = response.headers.get('x-codegen-manifest')
const manifest = manifestHeader
? (JSON.parse(decodeURIComponent(manifestHeader)) as CodegenManifest)
: null
return { filename, manifest }
}
type FormState = typeof initialFormState
export default function CodegenStudioClient() {
const [form, setForm] = useState<FormState>(initialFormState)
const [status, setStatus] = useState<FetchStatus>('idle')
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [manifest, setManifest] = useState<CodegenManifest | null>(null)
const { status, message, error, manifest, generate } = useCodegenData()
const runtimeDescription = useMemo(() => {
switch (form.runtime) {
@@ -112,125 +75,62 @@ export default function CodegenStudioClient() {
setForm((prev) => ({ ...prev, [key]: event.target.value }))
}
const handleSubmit = async () => {
setStatus('loading')
setError(null)
setMessage(null)
try {
const { filename, manifest } = await fetchZip(form)
setMessage(`Zip ${filename} created successfully.`)
setManifest(manifest)
setStatus('success')
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to generate the zip')
setManifest(null)
setStatus('idle')
}
}
const handleSubmit = () => generate(form)
return (
<Container maxWidth="md" className="py-16">
<Paper elevation={8} className="p-8 space-y-6">
<Stack spacing={2}>
<Typography variant="h3" component="h1">
Codegen Studio Export
</Typography>
<Typography variant="body1" color="text.secondary">
Configure a starter bundle for MetaBuilder packages and download it instantly.
</Typography>
</Stack>
<Stack spacing={3}>
<TextField
label="Project name"
value={form.projectName}
onChange={handleChange('projectName')}
fullWidth
<Container maxWidth="lg" className="py-16">
<Paper elevation={8} className="p-8">
<Stack spacing={5}>
<Header
title="Codegen Studio Export"
subtitle="Configure a starter bundle for MetaBuilder packages and download it instantly."
/>
<TextField
label="Package id"
value={form.packageId}
onChange={handleChange('packageId')}
fullWidth
/>
<TextField
select
label="Runtime"
value={form.runtime}
onChange={handleChange('runtime')}
fullWidth
>
{runtimeOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<Typography variant="body2" color="text.secondary">
{runtimeDescription}
</Typography>
<TextField
label="Tone"
value={form.tone}
onChange={handleChange('tone')}
fullWidth
/>
<TextField
label="Creative brief"
value={form.brief}
onChange={handleChange('brief')}
fullWidth
multiline
minRows={3}
/>
<Box>
<Button
variant="contained"
color="primary"
onClick={handleSubmit}
disabled={status === 'loading'}
startIcon={status === 'loading' ? <CircularProgress size={16} /> : null}
>
{status === 'loading' ? 'Generating...' : 'Generate ZIP'}
</Button>
</Box>
{message && <Alert severity="success">{message}</Alert>}
{error && <Alert severity="error">{error}</Alert>}
{manifest && (
<Paper
elevation={1}
sx={{ border: '1px dashed', borderColor: 'divider', p: 2, backgroundColor: 'background.default' }}
>
<Typography variant="subtitle1" gutterBottom>
Manifest preview
<Stack direction={{ xs: 'column', md: 'row' }} spacing={5} alignItems="flex-start">
<Stack spacing={3} flex={1} width="100%">
<TextField
label="Project name"
value={form.projectName}
onChange={handleChange('projectName')}
fullWidth
/>
<TextField label="Package id" value={form.packageId} onChange={handleChange('packageId')} fullWidth />
<TextField select label="Runtime" value={form.runtime} onChange={handleChange('runtime')} fullWidth>
{runtimeOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<Typography variant="body2" color="text.secondary">
{runtimeDescription}
</Typography>
<Stack spacing={0.5}>
<Typography variant="body2" color="text.secondary">
Project: {manifest.projectName}
</Typography>
<Typography variant="body2" color="text.secondary">
Package: {manifest.packageId}
</Typography>
<Typography variant="body2" color="text.secondary">
Runtime: {manifest.runtime}
</Typography>
<Typography variant="body2" color="text.secondary">
Tone: {manifest.tone ?? 'adaptive'}
</Typography>
<Typography variant="body2" color="text.secondary">
Generated at: {new Date(manifest.generatedAt).toLocaleString()}
</Typography>
</Stack>
</Paper>
)}
<Stack spacing={0.5}>
<Typography variant="subtitle2">Bundle contents</Typography>
{previewFiles.map((entry) => (
<Typography key={entry} variant="body2" color="text.secondary">
{entry}
</Typography>
))}
<TextField label="Tone" value={form.tone} onChange={handleChange('tone')} fullWidth />
<TextField
label="Creative brief"
value={form.brief}
onChange={handleChange('brief')}
fullWidth
multiline
minRows={3}
/>
<Box>
<Button
variant="contained"
color="primary"
onClick={handleSubmit}
disabled={status === 'loading'}
startIcon={status === 'loading' ? <CircularProgress size={16} /> : null}
>
{status === 'loading' ? 'Generating...' : 'Generate ZIP'}
</Button>
</Box>
{message && <Alert severity="success">{message}</Alert>}
{error && <Alert severity="error">{error}</Alert>}
</Stack>
<Box width={{ xs: '100%', md: 320 }} flexShrink={0}>
<Sidebar manifest={manifest} previewFiles={previewFiles} />
</Box>
</Stack>
</Stack>
</Paper>

View File

@@ -0,0 +1,21 @@
'use client'
import { Stack, Typography } from '@mui/material'
interface HeaderProps {
title: string
subtitle: string
}
export default function Header({ title, subtitle }: HeaderProps) {
return (
<Stack spacing={2}>
<Typography variant="h3" component="h1">
{title}
</Typography>
<Typography variant="body1" color="text.secondary">
{subtitle}
</Typography>
</Stack>
)
}

View File

@@ -0,0 +1,51 @@
'use client'
import type { CodegenManifest } from '@/lib/codegen/codegen-types'
import { Paper, Stack, Typography } from '@mui/material'
interface SidebarProps {
manifest: CodegenManifest | null
previewFiles: string[]
}
export default function Sidebar({ manifest, previewFiles }: SidebarProps) {
return (
<Stack spacing={3}>
{manifest && (
<Paper
elevation={1}
sx={{ border: '1px dashed', borderColor: 'divider', p: 2, backgroundColor: 'background.default' }}
>
<Typography variant="subtitle1" gutterBottom>
Manifest preview
</Typography>
<Stack spacing={0.5}>
<Typography variant="body2" color="text.secondary">
Project: {manifest.projectName}
</Typography>
<Typography variant="body2" color="text.secondary">
Package: {manifest.packageId}
</Typography>
<Typography variant="body2" color="text.secondary">
Runtime: {manifest.runtime}
</Typography>
<Typography variant="body2" color="text.secondary">
Tone: {manifest.tone ?? 'adaptive'}
</Typography>
<Typography variant="body2" color="text.secondary">
Generated at: {new Date(manifest.generatedAt).toLocaleString()}
</Typography>
</Stack>
</Paper>
)}
<Stack spacing={1}>
<Typography variant="subtitle2">Bundle contents</Typography>
{previewFiles.map((entry) => (
<Typography key={entry} variant="body2" color="text.secondary">
{entry}
</Typography>
))}
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,74 @@
'use client'
import type { CodegenManifest } from '@/lib/codegen/codegen-types'
import { useCallback, useState } from 'react'
export type CodegenRequest = {
projectName: string
packageId: string
runtime: string
tone: string
brief: string
}
export type FetchStatus = 'idle' | 'loading' | 'success'
const createFilename = (header: string | null, fallback: string) => {
const match = header?.match(/filename="?([^"]+)"?/) ?? null
return match ? match[1] : fallback
}
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
const fetchZip = async (values: CodegenRequest) => {
const response = await fetch('/api/codegen/studio', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
})
if (!response.ok) {
throw new Error('Codegen Studio service returned an error')
}
const blob = await response.blob()
const filename = createFilename(response.headers.get('content-disposition'), `${values.projectName}.zip`)
downloadBlob(blob, filename)
const manifestHeader = response.headers.get('x-codegen-manifest')
const manifest = manifestHeader
? (JSON.parse(decodeURIComponent(manifestHeader)) as CodegenManifest)
: null
return { filename, manifest }
}
export function useCodegenData() {
const [status, setStatus] = useState<FetchStatus>('idle')
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [manifest, setManifest] = useState<CodegenManifest | null>(null)
const generate = useCallback(async (values: CodegenRequest) => {
setStatus('loading')
setError(null)
setMessage(null)
try {
const { filename, manifest: manifestResult } = await fetchZip(values)
setMessage(`Zip ${filename} created successfully.`)
setManifest(manifestResult)
setStatus('success')
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to generate the zip')
setManifest(null)
setStatus('idle')
}
}, [])
return { status, message, error, manifest, generate }
}

View File

@@ -1,12 +1,13 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui'
import { Button } from '@/components/ui'
import { Alert, AlertDescription } from '@/components/ui'
import { FloppyDisk, X, Warning, ShieldCheck } from '@phosphor-icons/react'
import { useEffect, useState } from 'react'
import { Alert, AlertDescription, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { Warning } from '@phosphor-icons/react'
import Editor from '@monaco-editor/react'
import { toast } from 'sonner'
import { SchemaSection } from './json/SchemaSection'
import { Toolbar } from './json/Toolbar'
import { securityScanner, type SecurityScanResult } from '@/lib/security-scanner'
import { SecurityWarningDialog } from '@/components/organisms/security/SecurityWarningDialog'
import { toast } from 'sonner'
interface JsonEditorProps {
open: boolean
@@ -32,10 +33,12 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
}
}, [open, value])
const parseJson = () => JSON.parse(jsonText)
const handleSave = () => {
try {
const parsed = JSON.parse(jsonText)
const parsed = parseJson()
const scanResult = securityScanner.scanJSON(jsonText)
setSecurityScanResult(scanResult)
@@ -66,8 +69,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
const handleForceSave = () => {
try {
const parsed = JSON.parse(jsonText)
onSave(parsed)
onSave(parseJson())
setError(null)
setPendingSave(false)
setShowSecurityDialog(false)
@@ -81,7 +83,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
const scanResult = securityScanner.scanJSON(jsonText)
setSecurityScanResult(scanResult)
setShowSecurityDialog(true)
if (scanResult.safe) {
toast.success('No security issues detected')
} else {
@@ -91,8 +93,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
const handleFormat = () => {
try {
const parsed = JSON.parse(jsonText)
setJsonText(JSON.stringify(parsed, null, 2))
setJsonText(JSON.stringify(parseJson(), null, 2))
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Invalid JSON - cannot format')
@@ -106,7 +107,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
<DialogHeader>
<DialogTitle className="text-2xl">{title}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{error && (
<Alert variant="destructive">
@@ -115,16 +116,21 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
</Alert>
)}
{securityScanResult && securityScanResult.severity !== 'safe' && securityScanResult.severity !== 'low' && !showSecurityDialog && (
<Alert className="border-yellow-200 bg-yellow-50">
<Warning className="h-5 w-5 text-yellow-600" weight="fill" />
<AlertDescription className="text-yellow-800">
{securityScanResult.issues.length} security {securityScanResult.issues.length === 1 ? 'issue' : 'issues'} detected.
Click Security Scan to review.
</AlertDescription>
</Alert>
)}
{securityScanResult &&
securityScanResult.severity !== 'safe' &&
securityScanResult.severity !== 'low' &&
!showSecurityDialog && (
<Alert className="border-yellow-200 bg-yellow-50">
<Warning className="h-5 w-5 text-yellow-600" weight="fill" />
<AlertDescription className="text-yellow-800">
{securityScanResult.issues.length} security {securityScanResult.issues.length === 1 ? 'issue' : 'issues'}
 detected. Click Security Scan to review.
</AlertDescription>
</Alert>
)}
<SchemaSection schema={schema} />
<div className="border rounded-lg overflow-hidden">
<Editor
height="600px"
@@ -157,23 +163,12 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={handleScan}>
<ShieldCheck className="mr-2" />
Security Scan
</Button>
<Button variant="outline" onClick={handleFormat}>
Format JSON
</Button>
<Button variant="outline" onClick={onClose}>
<X className="mr-2" />
Cancel
</Button>
<Button onClick={handleSave} className="bg-accent text-accent-foreground hover:bg-accent/90">
<FloppyDisk className="mr-2" />
Save
</Button>
</DialogFooter>
<Toolbar
onScan={handleScan}
onFormat={handleFormat}
onCancel={onClose}
onSave={handleSave}
/>
</DialogContent>
</Dialog>

View File

@@ -1,79 +1,15 @@
import { useState, useEffect } from 'react'
import { useEffect, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Label } from '@/components/ui'
import { Input } from '@/components/ui'
import { Button } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { Switch } from '@/components/ui'
import { Palette, Sun, Moon, FloppyDisk, ArrowCounterClockwise } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { useKV } from '@github/spark/hooks'
interface ThemeColors {
background: string
foreground: string
card: string
cardForeground: string
primary: string
primaryForeground: string
secondary: string
secondaryForeground: string
muted: string
mutedForeground: string
accent: string
accentForeground: string
destructive: string
destructiveForeground: string
border: string
input: string
ring: string
}
interface ThemeConfig {
light: ThemeColors
dark: ThemeColors
radius: string
}
const DEFAULT_LIGHT_THEME: ThemeColors = {
background: 'oklch(0.92 0.03 290)',
foreground: 'oklch(0.25 0.02 260)',
card: 'oklch(1 0 0)',
cardForeground: 'oklch(0.25 0.02 260)',
primary: 'oklch(0.55 0.18 290)',
primaryForeground: 'oklch(0.98 0 0)',
secondary: 'oklch(0.35 0.02 260)',
secondaryForeground: 'oklch(0.90 0.01 260)',
muted: 'oklch(0.95 0.02 290)',
mutedForeground: 'oklch(0.50 0.02 260)',
accent: 'oklch(0.70 0.17 195)',
accentForeground: 'oklch(0.2 0.02 260)',
destructive: 'oklch(0.55 0.22 25)',
destructiveForeground: 'oklch(0.98 0 0)',
border: 'oklch(0.85 0.02 290)',
input: 'oklch(0.85 0.02 290)',
ring: 'oklch(0.70 0.17 195)',
}
const DEFAULT_DARK_THEME: ThemeColors = {
background: 'oklch(0.145 0 0)',
foreground: 'oklch(0.985 0 0)',
card: 'oklch(0.205 0 0)',
cardForeground: 'oklch(0.985 0 0)',
primary: 'oklch(0.922 0 0)',
primaryForeground: 'oklch(0.205 0 0)',
secondary: 'oklch(0.269 0 0)',
secondaryForeground: 'oklch(0.985 0 0)',
muted: 'oklch(0.269 0 0)',
mutedForeground: 'oklch(0.708 0 0)',
accent: 'oklch(0.269 0 0)',
accentForeground: 'oklch(0.985 0 0)',
destructive: 'oklch(0.704 0.191 22.216)',
destructiveForeground: 'oklch(0.98 0 0)',
border: 'oklch(1 0 0 / 10%)',
input: 'oklch(1 0 0 / 15%)',
ring: 'oklch(0.556 0 0)',
}
import { PaletteEditor } from './theme/PaletteEditor'
import { PreviewPane } from './theme/PreviewPane'
import { DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME } from './theme/constants'
import { ThemeColors, ThemeConfig } from './theme/types'
export function ThemeEditor() {
const [themeConfig, setThemeConfig] = useKV<ThemeConfig>('theme_config', {
@@ -81,7 +17,7 @@ export function ThemeEditor() {
dark: DEFAULT_DARK_THEME,
radius: '0.5rem',
})
const [isDarkMode, setIsDarkMode] = useKV<boolean>('dark_mode_enabled', false)
const [editingTheme, setEditingTheme] = useState<'light' | 'dark'>('light')
const [localColors, setLocalColors] = useState<ThemeColors>(DEFAULT_LIGHT_THEME)
@@ -95,30 +31,19 @@ export function ThemeEditor() {
}, [editingTheme, themeConfig])
useEffect(() => {
if (themeConfig) {
applyTheme()
}
}, [themeConfig, isDarkMode])
const applyTheme = () => {
if (!themeConfig) return
const root = document.documentElement
const colors = isDarkMode ? themeConfig.dark : themeConfig.light
Object.entries(colors).forEach(([key, value]) => {
const cssVarName = key.replace(/([A-Z])/g, '-$1').toLowerCase()
root.style.setProperty(`--${cssVarName}`, value)
})
root.style.setProperty('--radius', themeConfig.radius)
if (isDarkMode) {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}
root.classList.toggle('dark', isDarkMode)
}, [isDarkMode, themeConfig])
const handleColorChange = (colorKey: keyof ThemeColors, value: string) => {
setLocalColors((current) => ({
@@ -130,12 +55,14 @@ export function ThemeEditor() {
const handleSave = () => {
setThemeConfig((current) => {
if (!current) return { light: localColors, dark: DEFAULT_DARK_THEME, radius: localRadius }
return {
...current,
[editingTheme]: localColors,
radius: localRadius,
}
})
toast.success('Theme saved successfully')
}
@@ -151,41 +78,6 @@ export function ThemeEditor() {
toast.success(checked ? 'Dark mode enabled' : 'Light mode enabled')
}
const colorGroups = [
{
title: 'Base Colors',
colors: [
{ key: 'background' as const, label: 'Background' },
{ key: 'foreground' as const, label: 'Foreground' },
{ key: 'card' as const, label: 'Card' },
{ key: 'cardForeground' as const, label: 'Card Foreground' },
],
},
{
title: 'Action Colors',
colors: [
{ key: 'primary' as const, label: 'Primary' },
{ key: 'primaryForeground' as const, label: 'Primary Foreground' },
{ key: 'secondary' as const, label: 'Secondary' },
{ key: 'secondaryForeground' as const, label: 'Secondary Foreground' },
{ key: 'accent' as const, label: 'Accent' },
{ key: 'accentForeground' as const, label: 'Accent Foreground' },
{ key: 'destructive' as const, label: 'Destructive' },
{ key: 'destructiveForeground' as const, label: 'Destructive Foreground' },
],
},
{
title: 'Supporting Colors',
colors: [
{ key: 'muted' as const, label: 'Muted' },
{ key: 'mutedForeground' as const, label: 'Muted Foreground' },
{ key: 'border' as const, label: 'Border' },
{ key: 'input' as const, label: 'Input' },
{ key: 'ring' as const, label: 'Ring' },
],
},
]
return (
<div className="space-y-6">
<Card>
@@ -196,9 +88,7 @@ export function ThemeEditor() {
<Palette size={24} />
Theme Editor
</CardTitle>
<CardDescription>
Customize the application theme colors and appearance
</CardDescription>
<CardDescription>Customize the application theme colors and appearance</CardDescription>
</div>
<div className="flex items-center gap-3">
<Sun size={18} className={!isDarkMode ? 'text-amber-500' : 'text-muted-foreground'} />
@@ -207,52 +97,21 @@ export function ThemeEditor() {
</div>
</div>
</CardHeader>
<CardContent>
<Tabs value={editingTheme} onValueChange={(v) => setEditingTheme(v as 'light' | 'dark')}>
<CardContent className="space-y-6">
<Tabs value={editingTheme} onValueChange={(value) => setEditingTheme(value as 'light' | 'dark')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="light">Light Theme</TabsTrigger>
<TabsTrigger value="dark">Dark Theme</TabsTrigger>
</TabsList>
<TabsContent value={editingTheme} className="space-y-6 mt-6">
<div className="space-y-4">
<div>
<Label htmlFor="radius">Border Radius</Label>
<Input
id="radius"
value={localRadius}
onChange={(e) => setLocalRadius(e.target.value)}
placeholder="e.g., 0.5rem"
className="mt-1.5"
/>
</div>
</div>
{colorGroups.map((group) => (
<div key={group.title} className="space-y-4">
<h3 className="text-sm font-semibold text-foreground">{group.title}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{group.colors.map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<Label htmlFor={key}>{label}</Label>
<div className="flex gap-2">
<div
className="w-10 h-10 rounded border border-border shrink-0"
style={{ background: localColors[key] }}
/>
<Input
id={key}
value={localColors[key]}
onChange={(e) => handleColorChange(key, e.target.value)}
placeholder="oklch(...)"
className="font-mono text-sm"
/>
</div>
</div>
))}
</div>
</div>
))}
<TabsContent value={editingTheme} className="space-y-6">
<PaletteEditor
colors={localColors}
radius={localRadius}
onColorChange={handleColorChange}
onRadiusChange={setLocalRadius}
/>
<div className="flex items-center gap-3 pt-4 border-t border-border">
<Button onClick={handleSave} className="gap-2">
@@ -267,26 +126,7 @@ export function ThemeEditor() {
</TabsContent>
</Tabs>
<div className="mt-6 p-4 border border-border rounded-lg bg-muted/30">
<h4 className="text-sm font-semibold mb-3">Theme Preview</h4>
<div className="space-y-3">
<div className="flex gap-2">
<Button size="sm">Primary Button</Button>
<Button size="sm" variant="secondary">Secondary</Button>
<Button size="sm" variant="outline">Outline</Button>
<Button size="sm" variant="destructive">Destructive</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Card Example</CardTitle>
<CardDescription>This is a card description</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Card content with muted text</p>
</CardContent>
</Card>
</div>
</div>
<PreviewPane />
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,26 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
interface SchemaSectionProps {
schema?: unknown
}
export function SchemaSection({ schema }: SchemaSectionProps) {
if (!schema) return null
const formattedSchema =
typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2)
return (
<Card>
<CardHeader className="flex flex-col gap-1">
<CardTitle>Schema</CardTitle>
<CardDescription>Reference for the expected JSON structure</CardDescription>
</CardHeader>
<CardContent>
<pre className="max-h-48 overflow-auto rounded border bg-muted px-3 py-2 text-xs leading-5 whitespace-pre-wrap">
{formattedSchema}
</pre>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,31 @@
import { Button, DialogFooter } from '@/components/ui'
import { FloppyDisk, ShieldCheck, X } from '@phosphor-icons/react'
interface ToolbarProps {
onScan: () => void
onFormat: () => void
onCancel: () => void
onSave: () => void
}
export function Toolbar({ onScan, onFormat, onCancel, onSave }: ToolbarProps) {
return (
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onScan}>
<ShieldCheck className="mr-2" />
Security Scan
</Button>
<Button variant="outline" onClick={onFormat}>
Format JSON
</Button>
<Button variant="outline" onClick={onCancel}>
<X className="mr-2" />
Cancel
</Button>
<Button onClick={onSave} className="bg-accent text-accent-foreground hover:bg-accent/90">
<FloppyDisk className="mr-2" />
Save
</Button>
</DialogFooter>
)
}

View File

@@ -0,0 +1,95 @@
import type { MouseEvent } from 'react'
import { Box, Button, Card, CardContent, CardHeader, Stack, TextField, Typography } from '@mui/material'
import { Add as AddIcon } from '@mui/icons-material'
import type { LuaScript } from '@/lib/level-types'
import type { BlockDefinition, BlockSlot, LuaBlock, LuaBlockType } from './types'
import { BlockList } from './blocks/BlockList'
import styles from './LuaBlocksEditor.module.scss'
interface BlockListViewProps {
activeBlocks: LuaBlock[]
blockDefinitionMap: Map<LuaBlockType, BlockDefinition>
onRequestAddBlock: (
event: MouseEvent<HTMLElement>,
target: { parentId: string | null; slot: BlockSlot }
) => void
onMoveBlock: (blockId: string, direction: 'up' | 'down') => void
onDuplicateBlock: (blockId: string) => void
onRemoveBlock: (blockId: string) => void
onUpdateField: (blockId: string, fieldName: string, value: string) => void
onUpdateScript: (updates: Partial<LuaScript>) => void
selectedScript: LuaScript | null
}
export function BlockListView({
activeBlocks,
blockDefinitionMap,
onRequestAddBlock,
onMoveBlock,
onDuplicateBlock,
onRemoveBlock,
onUpdateField,
onUpdateScript,
selectedScript,
}: BlockListViewProps) {
return (
<Card>
<CardHeader
title="Block workspace"
subheader="Stack blocks to generate Lua code"
action={
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={(event) => onRequestAddBlock(event, { parentId: null, slot: 'root' })}
disabled={!selectedScript}
>
Add block
</Button>
}
/>
<CardContent>
{!selectedScript ? (
<Typography variant="body2" color="text.secondary">
Select a script to start building blocks.
</Typography>
) : (
<Stack spacing={3}>
<Stack spacing={2} direction={{ xs: 'column', md: 'row' }}>
<TextField
label="Script name"
value={selectedScript.name}
onChange={(event) => onUpdateScript({ name: event.target.value })}
fullWidth
/>
<TextField
label="Description"
value={selectedScript.description || ''}
onChange={(event) => onUpdateScript({ description: event.target.value })}
fullWidth
/>
</Stack>
<Box className={styles.workspaceSurface}>
{activeBlocks.length > 0 ? (
<BlockList
blocks={activeBlocks}
blockDefinitionMap={blockDefinitionMap}
onRequestAddBlock={onRequestAddBlock}
onMoveBlock={onMoveBlock}
onDuplicateBlock={onDuplicateBlock}
onRemoveBlock={onRemoveBlock}
onUpdateField={onUpdateField}
/>
) : (
<Box className={styles.blockEmpty}>Add a block to start building Lua logic.</Box>
)}
</Box>
<Typography variant="caption" color="text.secondary">
Blocks are saved in the script as metadata, so you can reload them later.
</Typography>
</Stack>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,73 @@
import { Box, Button, Card, CardContent, CardHeader, Stack, Tooltip } from '@mui/material'
import { ContentCopy, Refresh as RefreshIcon, Save as SaveIcon } from '@mui/icons-material'
import type { LuaScript } from '@/lib/level-types'
import styles from './LuaBlocksEditor.module.scss'
interface CodePreviewProps {
generatedCode: string
onApplyCode: () => void
onCopyCode: () => void
onReloadFromCode: () => void
selectedScript: LuaScript | null
}
export function CodePreview({
generatedCode,
onApplyCode,
onCopyCode,
onReloadFromCode,
selectedScript,
}: CodePreviewProps) {
return (
<Card>
<CardHeader
title="Lua preview"
subheader="Generated code from your blocks"
action={
<Stack direction="row" spacing={1}>
<Tooltip title="Reload blocks from script">
<span>
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon fontSize="small" />}
onClick={onReloadFromCode}
disabled={!selectedScript}
>
Reload
</Button>
</span>
</Tooltip>
<Tooltip title="Copy code">
<span>
<Button
size="small"
variant="outlined"
startIcon={<ContentCopy fontSize="small" />}
onClick={onCopyCode}
disabled={!selectedScript}
>
Copy
</Button>
</span>
</Tooltip>
<Button
size="small"
variant="contained"
startIcon={<SaveIcon fontSize="small" />}
onClick={onApplyCode}
disabled={!selectedScript}
>
Apply to script
</Button>
</Stack>
}
/>
<CardContent>
<Box className={styles.codePreview}>
<pre>{generatedCode}</pre>
</Box>
</CardContent>
</Card>
)
}

View File

@@ -5,27 +5,21 @@ import {
CardContent,
CardHeader,
Divider,
IconButton,
List,
ListItemButton,
ListItemText,
Paper,
Stack,
TextField,
Tooltip,
Typography,
} from '@mui/material'
import {
Add as AddIcon,
ContentCopy,
Delete as DeleteIcon,
Refresh as RefreshIcon,
Save as SaveIcon,
} from '@mui/icons-material'
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'
import type { LuaScript } from '@/lib/level-types'
import { BlockList } from './blocks/BlockList'
import { BlockMenu } from './blocks/BlockMenu'
import { useBlockDefinitions } from './hooks/useBlockDefinitions'
import { useLuaBlocksState } from './hooks/useLuaBlocksState'
import { BlockListView } from './BlockListView'
import { CodePreview } from './CodePreview'
import { useLuaBlockEditorState } from './hooks/useLuaBlockEditorState'
import styles from './LuaBlocksEditor.module.scss'
interface LuaBlocksEditorProps {
@@ -34,18 +28,11 @@ interface LuaBlocksEditorProps {
}
export function LuaBlocksEditor({ scripts, onScriptsChange }: LuaBlocksEditorProps) {
const {
blockDefinitions,
blockDefinitionMap,
blocksByCategory,
createBlock,
cloneBlock,
buildLuaFromBlocks,
decodeBlocksMetadata,
} = useBlockDefinitions()
const {
activeBlocks,
blockDefinitionMap,
blockDefinitions,
blocksByCategory,
generatedCode,
handleAddBlock,
handleAddScript,
@@ -64,173 +51,7 @@ export function LuaBlocksEditor({ scripts, onScriptsChange }: LuaBlocksEditorPro
selectedScript,
selectedScriptId,
setSelectedScriptId,
} = useLuaBlocksState({
scripts,
onScriptsChange,
buildLuaFromBlocks,
createBlock,
cloneBlock,
decodeBlocksMetadata,
})
const renderBlockLibrary = () => (
<Card>
<CardHeader title="Block library" subheader="Click a block to add it" />
<CardContent>
<Stack spacing={2}>
{Object.entries(blocksByCategory).map(([category, blocks]) => (
<Box key={category}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
{category}
</Typography>
<Stack spacing={1}>
{blocks.map((block) => (
<Paper
key={block.type}
className={styles.libraryBlock}
data-category={block.category}
onClick={() => handleAddBlock(block.type, { parentId: null, slot: 'root' })}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
<Box>
<Typography className={styles.libraryBlockTitle}>{block.label}</Typography>
<Typography className={styles.libraryBlockDesc}>{block.description}</Typography>
</Box>
<Button
size="small"
variant="outlined"
onClick={(event) => {
event.stopPropagation()
handleAddBlock(block.type, { parentId: null, slot: 'root' })
}}
>
Add
</Button>
</Box>
</Paper>
))}
</Stack>
</Box>
))}
</Stack>
</CardContent>
</Card>
)
const renderWorkspace = () => (
<Card>
<CardHeader
title="Block workspace"
subheader="Stack blocks to generate Lua code"
action={
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={(event) => handleRequestAddBlock(event, { parentId: null, slot: 'root' })}
disabled={!selectedScript}
>
Add block
</Button>
}
/>
<CardContent>
{!selectedScript ? (
<Typography variant="body2" color="text.secondary">
Select a script to start building blocks.
</Typography>
) : (
<Stack spacing={3}>
<Stack spacing={2} direction={{ xs: 'column', md: 'row' }}>
<TextField
label="Script name"
value={selectedScript.name}
onChange={(event) => handleUpdateScript({ name: event.target.value })}
fullWidth
/>
<TextField
label="Description"
value={selectedScript.description || ''}
onChange={(event) => handleUpdateScript({ description: event.target.value })}
fullWidth
/>
</Stack>
<Box className={styles.workspaceSurface}>
{activeBlocks.length > 0 ? (
<BlockList
blocks={activeBlocks}
blockDefinitionMap={blockDefinitionMap}
onRequestAddBlock={handleRequestAddBlock}
onMoveBlock={handleMoveBlock}
onDuplicateBlock={handleDuplicateBlock}
onRemoveBlock={handleRemoveBlock}
onUpdateField={handleUpdateField}
/>
) : (
<Box className={styles.blockEmpty}>Add a block to start building Lua logic.</Box>
)}
</Box>
<Typography variant="caption" color="text.secondary">
Blocks are saved in the script as metadata, so you can reload them later.
</Typography>
</Stack>
)}
</CardContent>
</Card>
)
const renderScriptList = () => (
<Card>
<CardHeader
title="Lua block scripts"
subheader="Create scripts using Scratch-style blocks"
/>
<CardContent>
<Stack spacing={2}>
<Button variant="contained" startIcon={<AddIcon />} onClick={handleAddScript}>
New block script
</Button>
<Divider />
<List disablePadding>
{scripts.length === 0 && (
<Typography variant="body2" color="text.secondary">
No scripts yet. Create a block script to begin.
</Typography>
)}
{scripts.map((script) => (
<ListItemButton
key={script.id}
selected={script.id === selectedScriptId}
onClick={() => setSelectedScriptId(script.id)}
sx={{
borderRadius: 2,
mb: 1,
alignItems: 'flex-start',
}}
>
<ListItemText
primary={script.name}
secondary={script.description || 'No description'}
primaryTypographyProps={{ fontWeight: 600 }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
<Tooltip title="Delete script">
<IconButton
size="small"
onClick={(event) => {
event.stopPropagation()
handleDeleteScript(script.id)
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</ListItemButton>
))}
</List>
</Stack>
</CardContent>
</Card>
)
} = useLuaBlockEditorState({ scripts, onScriptsChange })
return (
<Box className={styles.root}>
@@ -242,55 +63,121 @@ export function LuaBlocksEditor({ scripts, onScriptsChange }: LuaBlocksEditorPro
}}
>
<Stack spacing={3}>
{renderScriptList()}
{renderBlockLibrary()}
<Card>
<CardHeader
title="Lua block scripts"
subheader="Create scripts using Scratch-style blocks"
/>
<CardContent>
<Stack spacing={2}>
<Button variant="contained" startIcon={<AddIcon />} onClick={handleAddScript}>
New block script
</Button>
<Divider />
<List disablePadding>
{scripts.length === 0 && (
<Typography variant="body2" color="text.secondary">
No scripts yet. Create a block script to begin.
</Typography>
)}
{scripts.map((script) => (
<ListItemButton
key={script.id}
selected={script.id === selectedScriptId}
onClick={() => setSelectedScriptId(script.id)}
sx={{
borderRadius: 2,
mb: 1,
alignItems: 'flex-start',
}}
>
<ListItemText
primary={script.name}
secondary={script.description || 'No description'}
primaryTypographyProps={{ fontWeight: 600 }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
<Tooltip title="Delete script">
<IconButton
size="small"
onClick={(event) => {
event.stopPropagation()
handleDeleteScript(script.id)
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</ListItemButton>
))}
</List>
</Stack>
</CardContent>
</Card>
<Card>
<CardHeader title="Block library" subheader="Click a block to add it" />
<CardContent>
<Stack spacing={2}>
{Object.entries(blocksByCategory).map(([category, blocks]) => (
<Box key={category}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
{category}
</Typography>
<Stack spacing={1}>
{blocks.map((block) => (
<Paper
key={block.type}
className={styles.libraryBlock}
data-category={block.category}
onClick={() => handleAddBlock(block.type, { parentId: null, slot: 'root' })}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
<Box>
<Typography className={styles.libraryBlockTitle}>{block.label}</Typography>
<Typography className={styles.libraryBlockDesc}>{block.description}</Typography>
</Box>
<Button
size="small"
variant="outlined"
onClick={(event) => {
event.stopPropagation()
handleAddBlock(block.type, { parentId: null, slot: 'root' })
}}
>
Add
</Button>
</Box>
</Paper>
))}
</Stack>
</Box>
))}
</Stack>
</CardContent>
</Card>
</Stack>
<Stack spacing={3}>
{renderWorkspace()}
<BlockListView
activeBlocks={activeBlocks}
blockDefinitionMap={blockDefinitionMap}
onRequestAddBlock={handleRequestAddBlock}
onMoveBlock={handleMoveBlock}
onDuplicateBlock={handleDuplicateBlock}
onRemoveBlock={handleRemoveBlock}
onUpdateField={handleUpdateField}
onUpdateScript={handleUpdateScript}
selectedScript={selectedScript}
/>
<Card>
<CardHeader
title="Lua preview"
subheader="Generated code from your blocks"
action={
<Stack direction="row" spacing={1}>
<Tooltip title="Reload blocks from script">
<span>
<IconButton
size="small"
onClick={handleReloadFromCode}
disabled={!selectedScript}
>
<RefreshIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Copy code">
<span>
<IconButton size="small" onClick={handleCopyCode} disabled={!selectedScript}>
<ContentCopy fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Button
size="small"
variant="contained"
startIcon={<SaveIcon fontSize="small" />}
onClick={handleApplyCode}
disabled={!selectedScript}
>
Apply to script
</Button>
</Stack>
}
/>
<CardContent>
<Box className={styles.codePreview}>
<pre>{generatedCode}</pre>
</Box>
</CardContent>
</Card>
<CodePreview
generatedCode={generatedCode}
onApplyCode={handleApplyCode}
onCopyCode={handleCopyCode}
onReloadFromCode={handleReloadFromCode}
selectedScript={selectedScript}
/>
</Stack>
</Box>

View File

@@ -1,34 +1,15 @@
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Input } from '@/components/ui'
import { Button } from '@/components/ui'
import { Badge } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Separator } from '@/components/ui'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui'
import {
MagnifyingGlass,
Copy,
Check,
BookOpen,
Tag,
ArrowRight,
Code
} from '@phosphor-icons/react'
import { useMemo, useState } from 'react'
import { Tabs } from '@/components/ui'
import { BookOpen } from '@phosphor-icons/react'
import { toast } from 'sonner'
import {
LUA_SNIPPET_CATEGORIES,
getSnippetsByCategory,
import {
getSnippetsByCategory,
searchSnippets,
type LuaSnippet
type LuaSnippet,
} from '@/lib/lua-snippets'
import { SearchBar } from './LuaSnippetLibrary/SearchBar'
import { SnippetDialog } from './LuaSnippetLibrary/SnippetDialog'
import { SnippetList } from './LuaSnippetLibrary/SnippetList'
interface LuaSnippetLibraryProps {
onInsertSnippet?: (code: string) => void
@@ -40,9 +21,11 @@ export function LuaSnippetLibrary({ onInsertSnippet }: LuaSnippetLibraryProps) {
const [selectedSnippet, setSelectedSnippet] = useState<LuaSnippet | null>(null)
const [copiedId, setCopiedId] = useState<string | null>(null)
const displayedSnippets = searchQuery
? searchSnippets(searchQuery)
: getSnippetsByCategory(selectedCategory)
const displayedSnippets = useMemo(
() =>
searchQuery ? searchSnippets(searchQuery) : getSnippetsByCategory(selectedCategory),
[searchQuery, selectedCategory]
)
const handleCopySnippet = (snippet: LuaSnippet) => {
navigator.clipboard.writeText(snippet.code)
@@ -72,214 +55,39 @@ export function LuaSnippetLibrary({ onInsertSnippet }: LuaSnippetLibraryProps) {
</p>
</div>
<div className="relative">
<MagnifyingGlass size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search snippets by name, description, or tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Tabs value={selectedCategory} onValueChange={setSelectedCategory}>
<ScrollArea className="w-full whitespace-nowrap">
<TabsList className="inline-flex w-auto">
{LUA_SNIPPET_CATEGORIES.map((category) => (
<TabsTrigger key={category} value={category} className="text-xs">
{category}
</TabsTrigger>
))}
</TabsList>
</ScrollArea>
<SearchBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
/>
{LUA_SNIPPET_CATEGORIES.map((category) => (
<TabsContent key={category} value={category} className="mt-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{displayedSnippets.length === 0 ? (
<div className="col-span-full text-center py-12 text-muted-foreground">
<Code size={48} className="mx-auto mb-4 opacity-50" />
<p>No snippets found</p>
{searchQuery && (
<p className="text-sm mt-2">Try a different search term</p>
)}
</div>
) : (
displayedSnippets.map((snippet) => (
<Card
key={snippet.id}
className="hover:border-primary transition-colors cursor-pointer group"
onClick={() => setSelectedSnippet(snippet)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="text-base font-semibold mb-1 truncate group-hover:text-primary transition-colors">
{snippet.name}
</CardTitle>
<CardDescription className="text-xs line-clamp-2">
{snippet.description}
</CardDescription>
</div>
<Badge variant="outline" className="text-xs shrink-0">
{snippet.category}
</Badge>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap gap-1.5 mb-3">
{snippet.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
<Tag size={12} className="mr-1" />
{tag}
</Badge>
))}
{snippet.tags.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{snippet.tags.length - 3}
</Badge>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={(e) => {
e.stopPropagation()
handleCopySnippet(snippet)
}}
>
{copiedId === snippet.id ? (
<>
<Check size={14} className="mr-1.5" />
Copied
</>
) : (
<>
<Copy size={14} className="mr-1.5" />
Copy
</>
)}
</Button>
{onInsertSnippet && (
<Button
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation()
handleInsertSnippet(snippet)
}}
>
<ArrowRight size={14} className="mr-1.5" />
Insert
</Button>
)}
</div>
</CardContent>
</Card>
))
)}
</div>
</TabsContent>
))}
<SnippetList
snippets={displayedSnippets}
searchQuery={searchQuery}
selectedCategory={selectedCategory}
onSelectSnippet={setSelectedSnippet}
onCopySnippet={handleCopySnippet}
onInsertSnippet={onInsertSnippet ? handleInsertSnippet : undefined}
copiedId={copiedId}
/>
</Tabs>
<Dialog open={!!selectedSnippet} onOpenChange={() => setSelectedSnippet(null)}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<DialogTitle className="text-xl mb-2">{selectedSnippet?.name}</DialogTitle>
<DialogDescription>{selectedSnippet?.description}</DialogDescription>
</div>
<Badge variant="outline">{selectedSnippet?.category}</Badge>
</div>
</DialogHeader>
<div className="flex-1 overflow-auto space-y-4">
{selectedSnippet?.tags && selectedSnippet.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedSnippet.tags.map((tag) => (
<Badge key={tag} variant="secondary">
<Tag size={12} className="mr-1" />
{tag}
</Badge>
))}
</div>
)}
{selectedSnippet?.parameters && selectedSnippet.parameters.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Code size={16} />
Parameters
</h4>
<div className="space-y-2">
{selectedSnippet.parameters.map((param) => (
<div key={param.name} className="bg-muted/50 rounded-lg p-3 border">
<div className="flex items-center gap-2 mb-1">
<code className="text-sm font-mono font-semibold text-primary">
{param.name}
</code>
<Badge variant="outline" className="text-xs">
{param.type}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{param.description}</p>
</div>
))}
</div>
</div>
)}
<Separator />
<div>
<h4 className="text-sm font-semibold mb-3">Code</h4>
<div className="bg-slate-950 text-slate-50 rounded-lg p-4 overflow-x-auto">
<pre className="text-xs font-mono leading-relaxed">
<code>{selectedSnippet?.code}</code>
</pre>
</div>
</div>
<div className="flex gap-2">
<Button
className="flex-1"
onClick={() => selectedSnippet && handleCopySnippet(selectedSnippet)}
>
{copiedId === selectedSnippet?.id ? (
<>
<Check size={16} className="mr-2" />
Copied to Clipboard
</>
) : (
<>
<Copy size={16} className="mr-2" />
Copy to Clipboard
</>
)}
</Button>
{onInsertSnippet && (
<Button
variant="secondary"
className="flex-1"
onClick={() => {
if (selectedSnippet) {
handleInsertSnippet(selectedSnippet)
setSelectedSnippet(null)
}
}}
>
<ArrowRight size={16} className="mr-2" />
Insert into Editor
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
<SnippetDialog
snippet={selectedSnippet}
copiedId={copiedId}
onCopy={handleCopySnippet}
onInsert={
onInsertSnippet
? (snippet) => {
handleInsertSnippet(snippet)
setSelectedSnippet(null)
}
: undefined
}
onClose={() => setSelectedSnippet(null)}
/>
</div>
)
}

View File

@@ -0,0 +1,44 @@
import { MagnifyingGlass } from '@phosphor-icons/react'
import { Input, ScrollArea, TabsList, TabsTrigger } from '@/components/ui'
import { LUA_SNIPPET_CATEGORIES } from '@/lib/lua-snippets'
interface SearchBarProps {
searchQuery: string
onSearchChange: (value: string) => void
selectedCategory: string
onCategoryChange: (category: string) => void
}
export function SearchBar({
searchQuery,
onSearchChange,
selectedCategory,
onCategoryChange,
}: SearchBarProps) {
return (
<div className="space-y-4">
<div className="relative">
<MagnifyingGlass
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search snippets by name, description, or tags..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<ScrollArea className="w-full whitespace-nowrap">
<TabsList className="inline-flex w-auto">
{LUA_SNIPPET_CATEGORIES.map((category) => (
<TabsTrigger key={category} value={category} className="text-xs">
{category}
</TabsTrigger>
))}
</TabsList>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,116 @@
import {
Badge,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Separator,
} from '@/components/ui'
import { ArrowRight, Check, Code, Copy, Tag } from '@phosphor-icons/react'
import { type LuaSnippet } from '@/lib/lua-snippets'
interface SnippetDialogProps {
snippet: LuaSnippet | null
copiedId: string | null
onCopy: (snippet: LuaSnippet) => void
onInsert?: (snippet: LuaSnippet) => void
onClose: () => void
}
export function SnippetDialog({
snippet,
copiedId,
onCopy,
onInsert,
onClose,
}: SnippetDialogProps) {
return (
<Dialog open={!!snippet} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<DialogTitle className="text-xl mb-2">{snippet?.name}</DialogTitle>
<DialogDescription>{snippet?.description}</DialogDescription>
</div>
<Badge variant="outline">{snippet?.category}</Badge>
</div>
</DialogHeader>
<div className="flex-1 overflow-auto space-y-4">
{snippet?.tags && snippet.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{snippet.tags.map((tag) => (
<Badge key={tag} variant="secondary">
<Tag size={12} className="mr-1" />
{tag}
</Badge>
))}
</div>
)}
{snippet?.parameters && snippet.parameters.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Code size={16} />
Parameters
</h4>
<div className="space-y-2">
{snippet.parameters.map((param) => (
<div key={param.name} className="bg-muted/50 rounded-lg p-3 border">
<div className="flex items-center gap-2 mb-1">
<code className="text-sm font-mono font-semibold text-primary">{param.name}</code>
<Badge variant="outline" className="text-xs">
{param.type}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{param.description}</p>
</div>
))}
</div>
</div>
)}
<Separator />
<div>
<h4 className="text-sm font-semibold mb-3">Code</h4>
<div className="bg-slate-950 text-slate-50 rounded-lg p-4 overflow-x-auto">
<pre className="text-xs font-mono leading-relaxed">
<code>{snippet?.code}</code>
</pre>
</div>
</div>
<div className="flex gap-2">
<Button className="flex-1" onClick={() => snippet && onCopy(snippet)}>
{copiedId === snippet?.id ? (
<>
<Check size={16} className="mr-2" />
Copied to Clipboard
</>
) : (
<>
<Copy size={16} className="mr-2" />
Copy to Clipboard
</>
)}
</Button>
{onInsert && (
<Button
variant="secondary"
className="flex-1"
onClick={() => snippet && onInsert(snippet)}
>
<ArrowRight size={16} className="mr-2" />
Insert into Editor
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,125 @@
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
TabsContent,
} from '@/components/ui'
import { ArrowRight, Check, Code, Copy, Tag } from '@phosphor-icons/react'
import { LUA_SNIPPET_CATEGORIES, type LuaSnippet } from '@/lib/lua-snippets'
interface SnippetListProps {
snippets: LuaSnippet[]
searchQuery: string
selectedCategory: string
onSelectSnippet: (snippet: LuaSnippet) => void
onCopySnippet: (snippet: LuaSnippet) => void
onInsertSnippet?: (snippet: LuaSnippet) => void
copiedId: string | null
}
export function SnippetList({
snippets,
searchQuery,
selectedCategory,
onSelectSnippet,
onCopySnippet,
onInsertSnippet,
copiedId,
}: SnippetListProps) {
return (
<>
{LUA_SNIPPET_CATEGORIES.map((category) => (
<TabsContent key={category} value={category} className="mt-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{snippets.length === 0 ? (
<div className="col-span-full text-center py-12 text-muted-foreground">
<Code size={48} className="mx-auto mb-4 opacity-50" />
<p>No snippets found</p>
{searchQuery && <p className="text-sm mt-2">Try a different search term</p>}
</div>
) : (
snippets.map((snippet) => (
<Card
key={`${selectedCategory}-${snippet.id}`}
className="hover:border-primary transition-colors cursor-pointer group"
onClick={() => onSelectSnippet(snippet)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="text-base font-semibold mb-1 truncate group-hover:text-primary transition-colors">
{snippet.name}
</CardTitle>
<CardDescription className="text-xs line-clamp-2">
{snippet.description}
</CardDescription>
</div>
<Badge variant="outline" className="text-xs shrink-0">
{snippet.category}
</Badge>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap gap-1.5 mb-3">
{snippet.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
<Tag size={12} className="mr-1" />
{tag}
</Badge>
))}
{snippet.tags.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{snippet.tags.length - 3}
</Badge>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={(e) => {
e.stopPropagation()
onCopySnippet(snippet)
}}
>
{copiedId === snippet.id ? (
<>
<Check size={14} className="mr-1.5" />
Copied
</>
) : (
<>
<Copy size={14} className="mr-1.5" />
Copy
</>
)}
</Button>
{onInsertSnippet && (
<Button
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation()
onInsertSnippet(snippet)
}}
>
<ArrowRight size={14} className="mr-1.5" />
Insert
</Button>
)}
</div>
</CardContent>
</Card>
))
)}
</div>
</TabsContent>
))}
</>
)
}

View File

@@ -0,0 +1,218 @@
import type { MouseEvent } from 'react'
import {
Box,
Button,
IconButton,
MenuItem,
TextField,
Tooltip,
Typography,
} from '@mui/material'
import {
Add as AddIcon,
ArrowDownward,
ArrowUpward,
ContentCopy,
Delete as DeleteIcon,
} from '@mui/icons-material'
import type { BlockDefinition, BlockSlot, LuaBlock } from '../types'
import styles from '../LuaBlocksEditor.module.scss'
interface BlockItemProps {
block: LuaBlock
definition: BlockDefinition
index: number
total: number
onRequestAddBlock: (
event: MouseEvent<HTMLElement>,
target: { parentId: string | null; slot: BlockSlot }
) => void
onMoveBlock: (blockId: string, direction: 'up' | 'down') => void
onDuplicateBlock: (blockId: string) => void
onRemoveBlock: (blockId: string) => void
onUpdateField: (blockId: string, fieldName: string, value: string) => void
renderNestedList: (blocks?: LuaBlock[]) => JSX.Element
}
interface BlockSectionProps {
title: string
blocks: LuaBlock[] | undefined
parentId: string
slot: BlockSlot
onRequestAddBlock: (
event: MouseEvent<HTMLElement>,
target: { parentId: string | null; slot: BlockSlot }
) => void
renderNestedList: (blocks?: LuaBlock[]) => JSX.Element
}
const BlockSection = ({
title,
blocks,
parentId,
slot,
onRequestAddBlock,
renderNestedList,
}: BlockSectionProps) => (
<Box className={styles.blockSection}>
<Box className={styles.blockSectionHeader}>
<Typography className={styles.blockSectionTitle}>{title}</Typography>
<Button
size="small"
variant="contained"
onClick={(event) => onRequestAddBlock(event, { parentId, slot })}
startIcon={<AddIcon fontSize="small" />}
>
Add block
</Button>
</Box>
<Box className={styles.blockSectionBody}>
{blocks && blocks.length > 0 ? (
renderNestedList(blocks)
) : (
<Box className={styles.blockEmpty}>Drop blocks here to build this section.</Box>
)}
</Box>
</Box>
)
const BlockFields = ({
block,
definition,
onUpdateField,
}: {
block: LuaBlock
definition: BlockDefinition
onUpdateField: (blockId: string, fieldName: string, value: string) => void
}) => {
if (definition.fields.length === 0) return null
return (
<Box className={styles.blockFields}>
{definition.fields.map((field) => (
<Box key={field.name}>
<Typography className={styles.blockFieldLabel}>{field.label}</Typography>
{field.type === 'select' ? (
<TextField
select
size="small"
value={block.fields[field.name]}
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
fullWidth
variant="outlined"
InputProps={{
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
}}
>
{field.options?.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
) : (
<TextField
size="small"
value={block.fields[field.name]}
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
placeholder={field.placeholder}
fullWidth
variant="outlined"
type={field.type === 'number' ? 'number' : 'text'}
InputProps={{
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
}}
/>
)}
</Box>
))}
</Box>
)
}
export const BlockItem = ({
block,
definition,
index,
total,
onRequestAddBlock,
onMoveBlock,
onDuplicateBlock,
onRemoveBlock,
onUpdateField,
renderNestedList,
}: BlockItemProps) => (
<Box className={styles.blockCard} data-category={definition.category}>
<Box className={styles.blockHeader}>
<Typography className={styles.blockTitle}>{definition.label}</Typography>
<Box className={styles.blockActions}>
<Tooltip title="Move up">
<span>
<IconButton
size="small"
onClick={() => onMoveBlock(block.id, 'up')}
disabled={index === 0}
sx={{ color: 'rgba(255,255,255,0.85)' }}
>
<ArrowUpward fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Move down">
<span>
<IconButton
size="small"
onClick={() => onMoveBlock(block.id, 'down')}
disabled={index === total - 1}
sx={{ color: 'rgba(255,255,255,0.85)' }}
>
<ArrowDownward fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Duplicate block">
<IconButton
size="small"
onClick={() => onDuplicateBlock(block.id)}
sx={{ color: 'rgba(255,255,255,0.85)' }}
>
<ContentCopy fontSize="inherit" />
</IconButton>
</Tooltip>
<Tooltip title="Delete block">
<IconButton
size="small"
onClick={() => onRemoveBlock(block.id)}
sx={{ color: 'rgba(255,255,255,0.85)' }}
>
<DeleteIcon fontSize="inherit" />
</IconButton>
</Tooltip>
</Box>
</Box>
<BlockFields block={block} definition={definition} onUpdateField={onUpdateField} />
{definition.hasChildren && (
<BlockSection
title="Then"
blocks={block.children}
parentId={block.id}
slot="children"
onRequestAddBlock={onRequestAddBlock}
renderNestedList={renderNestedList}
/>
)}
{definition.hasElseChildren && (
<BlockSection
title="Else"
blocks={block.elseChildren}
parentId={block.id}
slot="elseChildren"
onRequestAddBlock={onRequestAddBlock}
renderNestedList={renderNestedList}
/>
)}
</Box>
)

View File

@@ -1,22 +1,8 @@
import type { MouseEvent } from 'react'
import {
Box,
Button,
IconButton,
MenuItem,
TextField,
Tooltip,
Typography,
} from '@mui/material'
import {
Add as AddIcon,
ArrowDownward,
ArrowUpward,
ContentCopy,
Delete as DeleteIcon,
} from '@mui/icons-material'
import { Box } from '@mui/material'
import type { BlockDefinition, BlockSlot, LuaBlock, LuaBlockType } from '../types'
import styles from '../LuaBlocksEditor.module.scss'
import { BlockItem } from './BlockItem'
interface BlockListProps {
blocks: LuaBlock[]
@@ -31,89 +17,6 @@ interface BlockListProps {
onUpdateField: (blockId: string, fieldName: string, value: string) => void
}
const renderBlockFields = (
block: LuaBlock,
definition: BlockDefinition,
onUpdateField: (blockId: string, fieldName: string, value: string) => void
) => {
if (definition.fields.length === 0) return null
return (
<Box className={styles.blockFields}>
{definition.fields.map((field) => (
<Box key={field.name}>
<Typography className={styles.blockFieldLabel}>{field.label}</Typography>
{field.type === 'select' ? (
<TextField
select
size="small"
value={block.fields[field.name]}
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
fullWidth
variant="outlined"
InputProps={{
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
}}
>
{field.options?.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
) : (
<TextField
size="small"
value={block.fields[field.name]}
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
placeholder={field.placeholder}
fullWidth
variant="outlined"
type={field.type === 'number' ? 'number' : 'text'}
InputProps={{
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
}}
/>
)}
</Box>
))}
</Box>
)
}
const renderBlockSection = (
title: string,
blocks: LuaBlock[] | undefined,
parentId: string | null,
slot: BlockSlot,
onRequestAddBlock: (
event: MouseEvent<HTMLElement>,
target: { parentId: string | null; slot: BlockSlot }
) => void,
renderBlockCard: (block: LuaBlock, index: number, total: number) => JSX.Element | null
) => (
<Box className={styles.blockSection}>
<Box className={styles.blockSectionHeader}>
<Typography className={styles.blockSectionTitle}>{title}</Typography>
<Button
size="small"
variant="contained"
onClick={(event) => onRequestAddBlock(event, { parentId, slot })}
startIcon={<AddIcon fontSize="small" />}
>
Add block
</Button>
</Box>
<Box className={styles.blockSectionBody}>
{blocks && blocks.length > 0 ? (
blocks.map((child, index) => renderBlockCard(child, index, blocks.length))
) : (
<Box className={styles.blockEmpty}>Drop blocks here to build this section.</Box>
)}
</Box>
</Box>
)
export const BlockList = ({
blocks,
blockDefinitionMap,
@@ -123,78 +26,40 @@ export const BlockList = ({
onRemoveBlock,
onUpdateField,
}: BlockListProps) => {
const renderBlockCard = (block: LuaBlock, index: number, total: number) => {
const definition = blockDefinitionMap.get(block.type)
if (!definition) return null
return (
<Box key={block.id} className={styles.blockCard} data-category={definition.category}>
<Box className={styles.blockHeader}>
<Typography className={styles.blockTitle}>{definition.label}</Typography>
<Box className={styles.blockActions}>
<Tooltip title="Move up">
<span>
<IconButton
size="small"
onClick={() => onMoveBlock(block.id, 'up')}
disabled={index === 0}
sx={{ color: 'rgba(255,255,255,0.85)' }}
>
<ArrowUpward fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Move down">
<span>
<IconButton
size="small"
onClick={() => onMoveBlock(block.id, 'down')}
disabled={index === total - 1}
sx={{ color: 'rgba(255,255,255,0.85)' }}
>
<ArrowDownward fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Duplicate block">
<IconButton
size="small"
onClick={() => onDuplicateBlock(block.id)}
sx={{ color: 'rgba(255,255,255,0.85)' }}
>
<ContentCopy fontSize="inherit" />
</IconButton>
</Tooltip>
<Tooltip title="Delete block">
<IconButton
size="small"
onClick={() => onRemoveBlock(block.id)}
sx={{ color: 'rgba(255,255,255,0.85)' }}
>
<DeleteIcon fontSize="inherit" />
</IconButton>
</Tooltip>
</Box>
</Box>
{renderBlockFields(block, definition, onUpdateField)}
{definition.hasChildren &&
renderBlockSection('Then', block.children, block.id, 'children', onRequestAddBlock, renderBlockCard)}
{definition.hasElseChildren &&
renderBlockSection(
'Else',
block.elseChildren,
block.id,
'elseChildren',
onRequestAddBlock,
renderBlockCard
)}
</Box>
)
}
const renderNestedList = (childBlocks?: LuaBlock[]) => (
<BlockList
blocks={childBlocks ?? []}
blockDefinitionMap={blockDefinitionMap}
onRequestAddBlock={onRequestAddBlock}
onMoveBlock={onMoveBlock}
onDuplicateBlock={onDuplicateBlock}
onRemoveBlock={onRemoveBlock}
onUpdateField={onUpdateField}
/>
)
return (
<Box className={styles.blockStack}>
{blocks.map((block, index) => renderBlockCard(block, index, blocks.length))}
{blocks.map((block, index) => {
const definition = blockDefinitionMap.get(block.type)
if (!definition) return null
return (
<BlockItem
key={block.id}
block={block}
definition={definition}
index={index}
total={blocks.length}
onRequestAddBlock={onRequestAddBlock}
onMoveBlock={onMoveBlock}
onDuplicateBlock={onDuplicateBlock}
onRemoveBlock={onRemoveBlock}
onUpdateField={onUpdateField}
renderNestedList={renderNestedList}
/>
)
})}
</Box>
)
}

View File

@@ -0,0 +1,20 @@
import type { BlockCategory, BlockDefinition } from '../types'
const createCategoryIndex = (): Record<BlockCategory, BlockDefinition[]> => ({
Basics: [],
Logic: [],
Loops: [],
Data: [],
Functions: [],
})
export const groupBlockDefinitionsByCategory = (definitions: BlockDefinition[]) => {
const categories = createCategoryIndex()
definitions.forEach((definition) => {
categories[definition.category].push(definition)
})
return categories
}
export const buildBlockDefinitionMap = (definitions: BlockDefinition[]) =>
new Map(definitions.map((definition) => [definition.type, definition]))

View File

@@ -1,4 +1,4 @@
import type { BlockCategory, BlockDefinition } from '../types'
import type { BlockDefinition } from '../types'
import { basicBlocks } from './basics'
import { dataBlocks } from './data'
import { functionBlocks } from './functions'
@@ -13,21 +13,4 @@ export const BLOCK_DEFINITIONS: BlockDefinition[] = [
...functionBlocks,
]
const createCategoryIndex = (): Record<BlockCategory, BlockDefinition[]> => ({
Basics: [],
Logic: [],
Loops: [],
Data: [],
Functions: [],
})
export const groupBlockDefinitionsByCategory = (definitions: BlockDefinition[]) => {
const categories = createCategoryIndex()
definitions.forEach((definition) => {
categories[definition.category].push(definition)
})
return categories
}
export const buildBlockDefinitionMap = (definitions: BlockDefinition[]) =>
new Map(definitions.map((definition) => [definition.type, definition]))
export { buildBlockDefinitionMap, groupBlockDefinitionsByCategory } from './grouping'

View File

@@ -0,0 +1,26 @@
import type { LuaScript } from '@/lib/level-types'
import { useBlockDefinitions } from './useBlockDefinitions'
import { useLuaBlocksState } from './useLuaBlocksState'
interface UseLuaBlockEditorStateProps {
scripts: LuaScript[]
onScriptsChange: (scripts: LuaScript[]) => void
}
export function useLuaBlockEditorState({ scripts, onScriptsChange }: UseLuaBlockEditorStateProps) {
const blockDefinitionState = useBlockDefinitions()
const luaBlockState = useLuaBlocksState({
scripts,
onScriptsChange,
buildLuaFromBlocks: blockDefinitionState.buildLuaFromBlocks,
createBlock: blockDefinitionState.createBlock,
cloneBlock: blockDefinitionState.cloneBlock,
decodeBlocksMetadata: blockDefinitionState.decodeBlocksMetadata,
})
return {
...blockDefinitionState,
...luaBlockState,
}
}

View File

@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState, type MouseEvent } from 'react'
import { toast } from 'sonner'
import { useEffect, useMemo, useState } from 'react'
import type { LuaScript } from '@/lib/level-types'
import type { BlockSlot, LuaBlock, LuaBlockType } from '../types'
import type { LuaBlock, LuaBlockType } from '../types'
import { createLuaBlocksActions, type MenuTarget } from './useLuaBlocksState/actions'
import { selectActiveBlocks, selectSelectedScript } from './useLuaBlocksState/selectors'
interface UseLuaBlocksStateProps {
scripts: LuaScript[]
@@ -12,108 +13,6 @@ interface UseLuaBlocksStateProps {
decodeBlocksMetadata: (code: string) => LuaBlock[] | null
}
interface MenuTarget {
parentId: string | null
slot: BlockSlot
}
const addBlockToTree = (
blocks: LuaBlock[],
parentId: string | null,
slot: BlockSlot,
newBlock: LuaBlock
): LuaBlock[] => {
if (slot === 'root' || !parentId) {
return [...blocks, newBlock]
}
return blocks.map((block) => {
if (block.id === parentId) {
const current = slot === 'children' ? block.children ?? [] : block.elseChildren ?? []
const updated = [...current, newBlock]
if (slot === 'children') {
return { ...block, children: updated }
}
return { ...block, elseChildren: updated }
}
const children = block.children ? addBlockToTree(block.children, parentId, slot, newBlock) : block.children
const elseChildren = block.elseChildren
? addBlockToTree(block.elseChildren, parentId, slot, newBlock)
: block.elseChildren
if (children !== block.children || elseChildren !== block.elseChildren) {
return { ...block, children, elseChildren }
}
return block
})
}
const updateBlockInTree = (
blocks: LuaBlock[],
blockId: string,
updater: (block: LuaBlock) => LuaBlock
): LuaBlock[] =>
blocks.map((block) => {
if (block.id === blockId) {
return updater(block)
}
const children = block.children ? updateBlockInTree(block.children, blockId, updater) : block.children
const elseChildren = block.elseChildren
? updateBlockInTree(block.elseChildren, blockId, updater)
: block.elseChildren
if (children !== block.children || elseChildren !== block.elseChildren) {
return { ...block, children, elseChildren }
}
return block
})
const removeBlockFromTree = (blocks: LuaBlock[], blockId: string): LuaBlock[] =>
blocks
.filter((block) => block.id !== blockId)
.map((block) => {
const children = block.children ? removeBlockFromTree(block.children, blockId) : block.children
const elseChildren = block.elseChildren
? removeBlockFromTree(block.elseChildren, blockId)
: block.elseChildren
if (children !== block.children || elseChildren !== block.elseChildren) {
return { ...block, children, elseChildren }
}
return block
})
const moveBlockInTree = (blocks: LuaBlock[], blockId: string, direction: 'up' | 'down'): LuaBlock[] => {
const index = blocks.findIndex((block) => block.id === blockId)
if (index !== -1) {
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= blocks.length) return blocks
const updated = [...blocks]
const [moved] = updated.splice(index, 1)
updated.splice(targetIndex, 0, moved)
return updated
}
return blocks.map((block) => {
const children = block.children ? moveBlockInTree(block.children, blockId, direction) : block.children
const elseChildren = block.elseChildren
? moveBlockInTree(block.elseChildren, blockId, direction)
: block.elseChildren
if (children !== block.children || elseChildren !== block.elseChildren) {
return { ...block, children, elseChildren }
}
return block
})
}
export function useLuaBlocksState({
scripts,
onScriptsChange,
@@ -156,178 +55,35 @@ export function useLuaBlocksState({
}))
}, [blocksByScript, decodeBlocksMetadata, scripts, selectedScriptId])
const selectedScript = scripts.find((script) => script.id === selectedScriptId) || null
const activeBlocks = selectedScriptId ? blocksByScript[selectedScriptId] || [] : []
const selectedScript = selectSelectedScript(scripts, selectedScriptId)
const activeBlocks = selectActiveBlocks(blocksByScript, selectedScriptId)
const generatedCode = useMemo(() => buildLuaFromBlocks(activeBlocks), [activeBlocks, buildLuaFromBlocks])
const handleAddScript = () => {
const starterBlocks = [createBlock('log')]
const newScript: LuaScript = {
id: `lua_${Date.now()}`,
name: 'Block Script',
description: 'Built with Lua blocks',
code: buildLuaFromBlocks(starterBlocks),
parameters: [],
}
onScriptsChange([...scripts, newScript])
setBlocksByScript((prev) => ({ ...prev, [newScript.id]: starterBlocks }))
setSelectedScriptId(newScript.id)
toast.success('Block script created')
}
const handleDeleteScript = (scriptId: string) => {
const remaining = scripts.filter((script) => script.id !== scriptId)
onScriptsChange(remaining)
setBlocksByScript((prev) => {
const { [scriptId]: _, ...rest } = prev
return rest
})
if (selectedScriptId === scriptId) {
setSelectedScriptId(remaining.length > 0 ? remaining[0].id : null)
}
toast.success('Script deleted')
}
const handleUpdateScript = (updates: Partial<LuaScript>) => {
if (!selectedScript) return
onScriptsChange(
scripts.map((script) => (script.id === selectedScript.id ? { ...script, ...updates } : script))
)
}
const handleApplyCode = () => {
if (!selectedScript) return
handleUpdateScript({ code: generatedCode })
toast.success('Lua code updated from blocks')
}
const handleCopyCode = async () => {
try {
await navigator.clipboard.writeText(generatedCode)
toast.success('Lua code copied to clipboard')
} catch (error) {
toast.error('Unable to copy code')
}
}
const handleReloadFromCode = () => {
if (!selectedScript) return
const parsed = decodeBlocksMetadata(selectedScript.code)
if (!parsed) {
toast.warning('No block metadata found in this script')
return
}
setBlocksByScript((prev) => ({ ...prev, [selectedScript.id]: parsed }))
toast.success('Blocks loaded from script')
}
const handleRequestAddBlock = (
event: MouseEvent<HTMLElement>,
target: { parentId: string | null; slot: BlockSlot }
) => {
setMenuAnchor(event.currentTarget)
setMenuTarget(target)
}
const handleAddBlock = (type: LuaBlockType, target?: { parentId: string | null; slot: BlockSlot }) => {
const resolvedTarget = target ?? menuTarget
if (!selectedScriptId || !resolvedTarget) return
const newBlock = createBlock(type)
setBlocksByScript((prev) => ({
...prev,
[selectedScriptId]: addBlockToTree(
prev[selectedScriptId] || [],
resolvedTarget.parentId,
resolvedTarget.slot,
newBlock
),
}))
setMenuAnchor(null)
setMenuTarget(null)
}
const handleCloseMenu = () => {
setMenuAnchor(null)
setMenuTarget(null)
}
const handleUpdateField = (blockId: string, fieldName: string, value: string) => {
if (!selectedScriptId) return
setBlocksByScript((prev) => ({
...prev,
[selectedScriptId]: updateBlockInTree(prev[selectedScriptId] || [], blockId, (block) => ({
...block,
fields: {
...block.fields,
[fieldName]: value,
},
})),
}))
}
const handleRemoveBlock = (blockId: string) => {
if (!selectedScriptId) return
setBlocksByScript((prev) => ({
...prev,
[selectedScriptId]: removeBlockFromTree(prev[selectedScriptId] || [], blockId),
}))
}
const handleDuplicateBlock = (blockId: string) => {
if (!selectedScriptId) return
setBlocksByScript((prev) => {
const blocks = prev[selectedScriptId] || []
let duplicated: LuaBlock | null = null
const updated = updateBlockInTree(blocks, blockId, (block) => {
duplicated = cloneBlock(block)
return block
})
if (!duplicated) return prev
return {
...prev,
[selectedScriptId]: addBlockToTree(updated, null, 'root', duplicated),
}
})
}
const handleMoveBlock = (blockId: string, direction: 'up' | 'down') => {
if (!selectedScriptId) return
setBlocksByScript((prev) => ({
...prev,
[selectedScriptId]: moveBlockInTree(prev[selectedScriptId] || [], blockId, direction),
}))
}
const actions = createLuaBlocksActions({
scripts,
selectedScript,
selectedScriptId,
generatedCode,
menuTarget,
buildLuaFromBlocks,
createBlock,
cloneBlock,
decodeBlocksMetadata,
onScriptsChange,
setBlocksByScript,
setMenuAnchor,
setMenuTarget,
setSelectedScriptId,
})
return {
activeBlocks,
generatedCode,
handleAddBlock,
handleAddScript,
handleApplyCode,
handleCloseMenu,
handleCopyCode,
handleDeleteScript,
handleDuplicateBlock,
handleMoveBlock,
handleReloadFromCode,
handleRemoveBlock,
handleRequestAddBlock,
handleUpdateField,
handleUpdateScript,
menuAnchor,
menuTarget,
selectedScript,
selectedScriptId,
setSelectedScriptId,
...actions,
}
}

View File

@@ -0,0 +1,208 @@
import type { Dispatch, MouseEvent, SetStateAction } from 'react'
import { toast } from 'sonner'
import type { LuaScript } from '@/lib/level-types'
import type { BlockSlot, LuaBlock, LuaBlockType } from '../../types'
import { addBlockToTree, moveBlockInTree, removeBlockFromTree, updateBlockInTree } from './storage'
export interface MenuTarget {
parentId: string | null
slot: BlockSlot
}
interface LuaBlocksActionConfig {
scripts: LuaScript[]
selectedScript: LuaScript | null
selectedScriptId: string | null
generatedCode: string
menuTarget: MenuTarget | null
buildLuaFromBlocks: (blocks: LuaBlock[]) => string
createBlock: (type: LuaBlockType) => LuaBlock
cloneBlock: (block: LuaBlock) => LuaBlock
decodeBlocksMetadata: (code: string) => LuaBlock[] | null
onScriptsChange: (scripts: LuaScript[]) => void
setBlocksByScript: Dispatch<SetStateAction<Record<string, LuaBlock[]>>>
setMenuAnchor: Dispatch<SetStateAction<HTMLElement | null>>
setMenuTarget: Dispatch<SetStateAction<MenuTarget | null>>
setSelectedScriptId: Dispatch<SetStateAction<string | null>>
}
export const createLuaBlocksActions = ({
scripts,
selectedScript,
selectedScriptId,
generatedCode,
menuTarget,
buildLuaFromBlocks,
createBlock,
cloneBlock,
decodeBlocksMetadata,
onScriptsChange,
setBlocksByScript,
setMenuAnchor,
setMenuTarget,
setSelectedScriptId,
}: LuaBlocksActionConfig) => {
const handleAddScript = () => {
const starterBlocks = [createBlock('log')]
const newScript: LuaScript = {
id: `lua_${Date.now()}`,
name: 'Block Script',
description: 'Built with Lua blocks',
code: buildLuaFromBlocks(starterBlocks),
parameters: [],
}
onScriptsChange([...scripts, newScript])
setBlocksByScript((prev) => ({ ...prev, [newScript.id]: starterBlocks }))
setSelectedScriptId(newScript.id)
toast.success('Block script created')
}
const handleDeleteScript = (scriptId: string) => {
const remaining = scripts.filter((script) => script.id !== scriptId)
onScriptsChange(remaining)
setBlocksByScript((prev) => {
const { [scriptId]: _, ...rest } = prev
return rest
})
if (selectedScriptId === scriptId) {
setSelectedScriptId(remaining.length > 0 ? remaining[0].id : null)
}
toast.success('Script deleted')
}
const handleUpdateScript = (updates: Partial<LuaScript>) => {
if (!selectedScript) return
onScriptsChange(
scripts.map((script) => (script.id === selectedScript.id ? { ...script, ...updates } : script))
)
}
const handleApplyCode = () => {
if (!selectedScript) return
handleUpdateScript({ code: generatedCode })
toast.success('Lua code updated from blocks')
}
const handleCopyCode = async () => {
try {
await navigator.clipboard.writeText(generatedCode)
toast.success('Lua code copied to clipboard')
} catch (error) {
toast.error('Unable to copy code')
}
}
const handleReloadFromCode = () => {
if (!selectedScript) return
const parsed = decodeBlocksMetadata(selectedScript.code)
if (!parsed) {
toast.warning('No block metadata found in this script')
return
}
setBlocksByScript((prev) => ({ ...prev, [selectedScript.id]: parsed }))
toast.success('Blocks loaded from script')
}
const handleRequestAddBlock = (
event: MouseEvent<HTMLElement>,
target: { parentId: string | null; slot: BlockSlot }
) => {
setMenuAnchor(event.currentTarget)
setMenuTarget(target)
}
const handleAddBlock = (type: LuaBlockType, target?: MenuTarget) => {
const resolvedTarget = target ?? menuTarget
if (!selectedScriptId || !resolvedTarget) return
const newBlock = createBlock(type)
setBlocksByScript((prev) => ({
...prev,
[selectedScriptId]: addBlockToTree(
prev[selectedScriptId] || [],
resolvedTarget.parentId,
resolvedTarget.slot,
newBlock
),
}))
setMenuAnchor(null)
setMenuTarget(null)
}
const handleCloseMenu = () => {
setMenuAnchor(null)
setMenuTarget(null)
}
const handleUpdateField = (blockId: string, fieldName: string, value: string) => {
if (!selectedScriptId) return
setBlocksByScript((prev) => ({
...prev,
[selectedScriptId]: updateBlockInTree(prev[selectedScriptId] || [], blockId, (block) => ({
...block,
fields: {
...block.fields,
[fieldName]: value,
},
})),
}))
}
const handleRemoveBlock = (blockId: string) => {
if (!selectedScriptId) return
setBlocksByScript((prev) => ({
...prev,
[selectedScriptId]: removeBlockFromTree(prev[selectedScriptId] || [], blockId),
}))
}
const handleDuplicateBlock = (blockId: string) => {
if (!selectedScriptId) return
setBlocksByScript((prev) => {
const blocks = prev[selectedScriptId] || []
let duplicated: LuaBlock | null = null
const updated = updateBlockInTree(blocks, blockId, (block) => {
duplicated = cloneBlock(block)
return block
})
if (!duplicated) return prev
return {
...prev,
[selectedScriptId]: addBlockToTree(updated, null, 'root', duplicated),
}
})
}
const handleMoveBlock = (blockId: string, direction: 'up' | 'down') => {
if (!selectedScriptId) return
setBlocksByScript((prev) => ({
...prev,
[selectedScriptId]: moveBlockInTree(prev[selectedScriptId] || [], blockId, direction),
}))
}
return {
handleAddBlock,
handleAddScript,
handleApplyCode,
handleCloseMenu,
handleCopyCode,
handleDeleteScript,
handleDuplicateBlock,
handleMoveBlock,
handleReloadFromCode,
handleRemoveBlock,
handleRequestAddBlock,
handleUpdateField,
handleUpdateScript,
}
}

View File

@@ -0,0 +1,12 @@
import type { LuaScript } from '@/lib/level-types'
import type { LuaBlock } from '../../types'
export const selectSelectedScript = (
scripts: LuaScript[],
selectedScriptId: string | null
): LuaScript | null => scripts.find((script) => script.id === selectedScriptId) || null
export const selectActiveBlocks = (
blocksByScript: Record<string, LuaBlock[]>,
selectedScriptId: string | null
): LuaBlock[] => (selectedScriptId ? blocksByScript[selectedScriptId] || [] : [])

View File

@@ -0,0 +1,98 @@
import type { BlockSlot, LuaBlock } from '../../types'
export const addBlockToTree = (
blocks: LuaBlock[],
parentId: string | null,
slot: BlockSlot,
newBlock: LuaBlock
): LuaBlock[] => {
if (slot === 'root' || !parentId) {
return [...blocks, newBlock]
}
return blocks.map((block) => {
if (block.id === parentId) {
const current = slot === 'children' ? block.children ?? [] : block.elseChildren ?? []
const updated = [...current, newBlock]
if (slot === 'children') {
return { ...block, children: updated }
}
return { ...block, elseChildren: updated }
}
const children = block.children ? addBlockToTree(block.children, parentId, slot, newBlock) : block.children
const elseChildren = block.elseChildren
? addBlockToTree(block.elseChildren, parentId, slot, newBlock)
: block.elseChildren
if (children !== block.children || elseChildren !== block.elseChildren) {
return { ...block, children, elseChildren }
}
return block
})
}
export const updateBlockInTree = (
blocks: LuaBlock[],
blockId: string,
updater: (block: LuaBlock) => LuaBlock
): LuaBlock[] =>
blocks.map((block) => {
if (block.id === blockId) {
return updater(block)
}
const children = block.children ? updateBlockInTree(block.children, blockId, updater) : block.children
const elseChildren = block.elseChildren
? updateBlockInTree(block.elseChildren, blockId, updater)
: block.elseChildren
if (children !== block.children || elseChildren !== block.elseChildren) {
return { ...block, children, elseChildren }
}
return block
})
export const removeBlockFromTree = (blocks: LuaBlock[], blockId: string): LuaBlock[] =>
blocks
.filter((block) => block.id !== blockId)
.map((block) => {
const children = block.children ? removeBlockFromTree(block.children, blockId) : block.children
const elseChildren = block.elseChildren
? removeBlockFromTree(block.elseChildren, blockId)
: block.elseChildren
if (children !== block.children || elseChildren !== block.elseChildren) {
return { ...block, children, elseChildren }
}
return block
})
export const moveBlockInTree = (blocks: LuaBlock[], blockId: string, direction: 'up' | 'down'): LuaBlock[] => {
const index = blocks.findIndex((block) => block.id === blockId)
if (index !== -1) {
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= blocks.length) return blocks
const updated = [...blocks]
const [moved] = updated.splice(index, 1)
updated.splice(targetIndex, 0, moved)
return updated
}
return blocks.map((block) => {
const children = block.children ? moveBlockInTree(block.children, blockId, direction) : block.children
const elseChildren = block.elseChildren
? moveBlockInTree(block.elseChildren, blockId, direction)
: block.elseChildren
if (children !== block.children || elseChildren !== block.elseChildren) {
return { ...block, children, elseChildren }
}
return block
})
}

View File

@@ -1,19 +1,9 @@
import { useState } from 'react'
import { Button } from '@/components/ui'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { Switch } from '@/components/ui'
import { SchemaTabs } from '@/components/schema/level4/Tabs'
import { useSchemaLevel4 } from '@/components/schema/level4/useSchemaLevel4'
import type { ModelSchema } from '@/lib/schema-types'
import { Plus, Trash } from '@phosphor-icons/react'
import { toast } from 'sonner'
import type { ModelSchema, FieldSchema, FieldType } from '@/lib/schema-types'
interface SchemaEditorLevel4Props {
schemas: ModelSchema[]
@@ -21,74 +11,17 @@ interface SchemaEditorLevel4Props {
}
export function SchemaEditorLevel4({ schemas, onSchemasChange }: SchemaEditorLevel4Props) {
const [selectedModel, setSelectedModel] = useState<string | null>(
schemas.length > 0 ? schemas[0].name : null
)
const currentModel = schemas.find(s => s.name === selectedModel)
const handleAddModel = () => {
const newModel: ModelSchema = {
name: `Model_${Date.now()}`,
label: 'New Model',
fields: [],
}
onSchemasChange([...schemas, newModel])
setSelectedModel(newModel.name)
toast.success('Model created')
}
const handleDeleteModel = (modelName: string) => {
onSchemasChange(schemas.filter(s => s.name !== modelName))
if (selectedModel === modelName) {
setSelectedModel(schemas.length > 1 ? schemas[0].name : null)
}
toast.success('Model deleted')
}
const handleUpdateModel = (updates: Partial<ModelSchema>) => {
if (!currentModel) return
onSchemasChange(
schemas.map(s => s.name === selectedModel ? { ...s, ...updates } : s)
)
}
const handleAddField = () => {
if (!currentModel) return
const newField: FieldSchema = {
name: `field_${Date.now()}`,
type: 'string',
label: 'New Field',
required: false,
editable: true,
}
handleUpdateModel({
fields: [...currentModel.fields, newField],
})
toast.success('Field added')
}
const handleDeleteField = (fieldName: string) => {
if (!currentModel) return
handleUpdateModel({
fields: currentModel.fields.filter(f => f.name !== fieldName),
})
toast.success('Field deleted')
}
const handleUpdateField = (fieldName: string, updates: Partial<FieldSchema>) => {
if (!currentModel) return
handleUpdateModel({
fields: currentModel.fields.map(f =>
f.name === fieldName ? { ...f, ...updates } : f
),
})
}
const {
currentModel,
selectedModel,
selectModel,
handleAddField,
handleAddModel,
handleDeleteField,
handleDeleteModel,
handleUpdateField,
handleUpdateModel,
} = useSchemaLevel4({ schemas, onSchemasChange })
return (
<div className="grid md:grid-cols-3 gap-6 h-full">
@@ -117,7 +50,7 @@ export function SchemaEditorLevel4({ schemas, onSchemasChange }: SchemaEditorLev
? 'bg-accent border-accent-foreground'
: 'hover:bg-muted border-border'
}`}
onClick={() => setSelectedModel(schema.name)}
onClick={() => selectModel(schema.name)}
>
<div>
<div className="font-medium text-sm">{schema.label || schema.name}</div>
@@ -150,179 +83,13 @@ export function SchemaEditorLevel4({ schemas, onSchemasChange }: SchemaEditorLev
</div>
</CardContent>
) : (
<>
<CardHeader>
<CardTitle>Edit Model: {currentModel.label}</CardTitle>
<CardDescription>Configure model properties and fields</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Model Name (ID)</Label>
<Input
value={currentModel.name}
onChange={(e) => handleUpdateModel({ name: e.target.value })}
placeholder="user_model"
/>
</div>
<div className="space-y-2">
<Label>Display Label</Label>
<Input
value={currentModel.label || ''}
onChange={(e) => handleUpdateModel({ label: e.target.value })}
placeholder="User"
/>
</div>
<div className="space-y-2">
<Label>Plural Label</Label>
<Input
value={currentModel.labelPlural || ''}
onChange={(e) => handleUpdateModel({ labelPlural: e.target.value })}
placeholder="Users"
/>
</div>
<div className="space-y-2">
<Label>Icon Name</Label>
<Input
value={currentModel.icon || ''}
onChange={(e) => handleUpdateModel({ icon: e.target.value })}
placeholder="Users"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-4">
<Label className="text-base">Fields</Label>
<Button size="sm" onClick={handleAddField}>
<Plus className="mr-2" size={16} />
Add Field
</Button>
</div>
<div className="space-y-4">
{currentModel.fields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8 border border-dashed rounded-lg">
No fields yet. Add a field to start.
</p>
) : (
currentModel.fields.map((field) => (
<Card key={field.name} className="border-2">
<CardContent className="pt-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="grid gap-4 md:grid-cols-2 flex-1">
<div className="space-y-2">
<Label className="text-xs">Field Name</Label>
<Input
value={field.name}
onChange={(e) =>
handleUpdateField(field.name, { name: e.target.value })
}
placeholder="email"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Label</Label>
<Input
value={field.label || ''}
onChange={(e) =>
handleUpdateField(field.name, { label: e.target.value })
}
placeholder="Email Address"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Type</Label>
<Select
value={field.type}
onValueChange={(value) =>
handleUpdateField(field.name, { type: value as FieldType })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">String</SelectItem>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="boolean">Boolean</SelectItem>
<SelectItem value="date">Date</SelectItem>
<SelectItem value="datetime">DateTime</SelectItem>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="url">URL</SelectItem>
<SelectItem value="select">Select</SelectItem>
<SelectItem value="relation">Relation</SelectItem>
<SelectItem value="json">JSON</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs">Default Value</Label>
<Input
value={field.default || ''}
onChange={(e) =>
handleUpdateField(field.name, { default: e.target.value })
}
placeholder="Default"
/>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteField(field.name)}
>
<Trash size={16} />
</Button>
</div>
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Switch
checked={field.required || false}
onCheckedChange={(checked) =>
handleUpdateField(field.name, { required: checked })
}
/>
<Label className="text-xs">Required</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={field.unique || false}
onCheckedChange={(checked) =>
handleUpdateField(field.name, { unique: checked })
}
/>
<Label className="text-xs">Unique</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={field.editable !== false}
onCheckedChange={(checked) =>
handleUpdateField(field.name, { editable: checked })
}
/>
<Label className="text-xs">Editable</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={field.searchable || false}
onCheckedChange={(checked) =>
handleUpdateField(field.name, { searchable: checked })
}
/>
<Label className="text-xs">Searchable</Label>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
</CardContent>
</>
<SchemaTabs
currentModel={currentModel}
onUpdateModel={handleUpdateModel}
onAddField={handleAddField}
onDeleteField={handleDeleteField}
onUpdateField={handleUpdateField}
/>
)}
</Card>
</div>

View File

@@ -0,0 +1,86 @@
import { Input, Label } from '@/components/ui'
import { ThemeColors } from './types'
const colorGroups = [
{
title: 'Base Colors',
colors: [
{ key: 'background' as const, label: 'Background' },
{ key: 'foreground' as const, label: 'Foreground' },
{ key: 'card' as const, label: 'Card' },
{ key: 'cardForeground' as const, label: 'Card Foreground' },
],
},
{
title: 'Action Colors',
colors: [
{ key: 'primary' as const, label: 'Primary' },
{ key: 'primaryForeground' as const, label: 'Primary Foreground' },
{ key: 'secondary' as const, label: 'Secondary' },
{ key: 'secondaryForeground' as const, label: 'Secondary Foreground' },
{ key: 'accent' as const, label: 'Accent' },
{ key: 'accentForeground' as const, label: 'Accent Foreground' },
{ key: 'destructive' as const, label: 'Destructive' },
{ key: 'destructiveForeground' as const, label: 'Destructive Foreground' },
],
},
{
title: 'Supporting Colors',
colors: [
{ key: 'muted' as const, label: 'Muted' },
{ key: 'mutedForeground' as const, label: 'Muted Foreground' },
{ key: 'border' as const, label: 'Border' },
{ key: 'input' as const, label: 'Input' },
{ key: 'ring' as const, label: 'Ring' },
],
},
]
interface PaletteEditorProps {
colors: ThemeColors
radius: string
onColorChange: (colorKey: keyof ThemeColors, value: string) => void
onRadiusChange: (value: string) => void
}
export function PaletteEditor({ colors, radius, onColorChange, onRadiusChange }: PaletteEditorProps) {
return (
<div className="space-y-6 mt-6">
<div className="space-y-4">
<div>
<Label htmlFor="radius">Border Radius</Label>
<Input
id="radius"
value={radius}
onChange={(e) => onRadiusChange(e.target.value)}
placeholder="e.g., 0.5rem"
className="mt-1.5"
/>
</div>
</div>
{colorGroups.map((group) => (
<div key={group.title} className="space-y-4">
<h3 className="text-sm font-semibold text-foreground">{group.title}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{group.colors.map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<Label htmlFor={key}>{label}</Label>
<div className="flex gap-2">
<div className="w-10 h-10 rounded border border-border shrink-0" style={{ background: colors[key] }} />
<Input
id={key}
value={colors[key]}
onChange={(e) => onColorChange(key, e.target.value)}
placeholder="oklch(...)"
className="font-mono text-sm"
/>
</div>
</div>
))}
</div>
</div>
))}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More