From af2218e99b81cd6fcaa91ce71504ea339989fb4d Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Tue, 10 Feb 2026 22:18:46 +0000 Subject: [PATCH 1/4] feat: implement SQL tool management page and refactor sidebar navigation into a dedicated module. --- api/routes.ts | 50 +++++ api/schema.ts | 18 ++ web/components/Dialog.tsx | 6 +- web/lib/navigation.tsx | 18 ++ web/lib/shared.tsx | 13 -- web/pages/DeploymentPage.tsx | 2 +- web/pages/ProjectPage.tsx | 3 +- web/pages/ToolsPage.tsx | 353 +++++++++++++++++++++++++++++++++++ 8 files changed, 446 insertions(+), 17 deletions(-) create mode 100644 web/lib/navigation.tsx create mode 100644 web/pages/ToolsPage.tsx diff --git a/api/routes.ts b/api/routes.ts index 407e71a..bc4850e 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -6,6 +6,8 @@ import { DeploymentDef, DeploymentsCollection, ProjectsCollection, + SQLToolDef, + SQLToolsCollection, TeamDef, TeamsCollection, UserDef, @@ -238,6 +240,54 @@ const defs = { output: BOOL('Indicates if the project was deleted'), description: 'Delete a project by ID', }), + 'GET/api/project/tools': route({ + authorize: withUserSession, + fn: (_ctx, { project }) => { + const tools = SQLToolsCollection.filter((t) => t.projectId === project) + return tools + }, + input: OBJ({ project: STR('The ID of the project') }), + output: ARR(SQLToolDef, 'List of SQL tools'), + description: 'Get SQL tools for a project', + }), + 'POST/api/project/tool': route({ + authorize: withAdminSession, + fn: (_ctx, input) => { + const toolId = crypto.randomUUID() + const tool = { ...input, toolId } + SQLToolsCollection.insert(tool) + return tool + }, + input: OBJ({ + projectId: STR('The ID of the project'), + name: STR('The name of the tool'), + targetTables: ARR( + STR('Target table names or *'), + 'List of target tables', + ), + targetColumns: ARR( + STR('Target column names or *'), + 'List of target columns', + ), + triggerEvent: LIST(['BEFORE', 'AFTER'], 'Trigger event: BEFORE or AFTER'), + code: STR('The JS function body'), + enabled: BOOL('Is the tool enabled?'), + }), + output: SQLToolDef, + description: 'Create a new SQL tool', + }), + 'DELETE/api/project/tool': route({ + authorize: withAdminSession, + fn: (_ctx, { id }) => { + const tool = SQLToolsCollection.get(id) + if (!tool) throw respond.NotFound({ message: 'Tool not found' }) + SQLToolsCollection.delete(id) + return true + }, + input: OBJ({ id: STR('The ID of the tool') }), + output: BOOL('Indicates if the tool was deleted'), + description: 'Delete a SQL tool', + }), 'GET/api/project/deployments': route({ authorize: withUserSession, fn: (_ctx, { project }) => { diff --git a/api/schema.ts b/api/schema.ts index 7b10848..7424e30 100644 --- a/api/schema.ts +++ b/api/schema.ts @@ -2,6 +2,7 @@ import { ARR, type Asserted, BOOL, + LIST, NUM, OBJ, optional, @@ -92,3 +93,20 @@ export const DatabaseSchemasCollection = await createCollection< DatabaseSchema, 'deploymentUrl' >({ name: 'db_schemas', primaryKey: 'deploymentUrl' }) + +export const SQLToolDef = OBJ({ + toolId: STR('The unique identifier for the tool'), + projectId: STR('The ID of the project this tool belongs to'), + name: STR('The name of the tool'), + targetTables: ARR(STR('Target table names or *'), 'List of target tables'), + targetColumns: ARR(STR('Target column names or *'), 'List of target columns'), + triggerEvent: LIST(['BEFORE', 'AFTER'], 'Trigger event: BEFORE or AFTER'), + code: STR('The JS function body'), + enabled: BOOL('Is the tool enabled?'), +}, 'The SQL tool definition') +export type SQLTool = Asserted + +export const SQLToolsCollection = await createCollection({ + name: 'sql_tools', + primaryKey: 'toolId', +}) diff --git a/web/components/Dialog.tsx b/web/components/Dialog.tsx index e21f195..70dac1b 100644 --- a/web/components/Dialog.tsx +++ b/web/components/Dialog.tsx @@ -44,10 +44,12 @@ export const Dialog = ({ ) } -export const DialogModal = ({ children, ...props }: DialogProps) => { +export const DialogModal = ( + { children, boxClass, ...props }: DialogProps & { boxClass?: string }, +) => { return ( - + ) +} + +const ToolList = () => ( +
+
+

+ Configured Tools ({tools.data?.length}) +

+
+ {tools.data?.length === 0 + ? ( +
+ +

No tools configured.

+ + Create your first tool + +
+ ) + : ( +
+ {tools.data?.map((tool) => ( + + ))} +
+ )} +
+) + +const onSubmit = async (e: TargetedEvent) => { + e.preventDefault() + if (!project.data?.slug) return + + const form = e.currentTarget + const formData = new FormData(form) + const name = formData.get('name') as string + const triggerEvent = formData.get('triggerEvent') as 'BEFORE' | 'AFTER' + const targetTables = (formData.get('targetTables') as string).split(',').map(s => s.trim()).filter(Boolean) + const targetColumns = (formData.get('targetColumns') as string).split(',').map(s => s.trim()).filter(Boolean) + const code = formData.get('code') as string + const enabled = formData.get('enabled') === 'on' + + await createTool.fetch({ + projectId: project.data.slug, + name, + triggerEvent, + targetTables, + targetColumns, + code, + enabled + }) + + if (!createTool.error) { + navigate({params: {dialog: null}}) + tools.fetch({project: project.data.slug}) + } +} + +const CreateToolModal = () => ( + +
+
+

+ + Create SQL Tool +

+
+ + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ +
+ + +
+
+ + + +
+
+ ) + +const ToolsSidebar = () => { + const activeTab = url.params.tab + if (!activeTab) { + navigate({ params: { tab: 'sql' }, replace: true }) + return null + } + return ( + + ) +} + +export function ToolsPage() { + return ( +
+ +
+ + New Tool + + } + /> + +
+
+ {tools.pending && tools.data?.length === 0 + ? ( +
+ +
+ ) + : } +
+
+ + +
+
+ ) +} From 39885eb4c048f73525873fe6b7b746bd8189c7ee Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Fri, 13 Feb 2026 10:08:35 +0000 Subject: [PATCH 2/4] feat: Implement server-side functions for SQL read transformation and remove the old Tools page. --- api/lib/functions.ts | 161 +++++++++++++++++ api/lib/functions_test.ts | 146 +++++++++++++++ api/lib/json_store.test.ts | 31 ++-- api/lib/json_store.ts | 2 +- api/routes.ts | 50 ------ api/schema.ts | 28 ++- api/server.ts | 3 + api/sql.ts | 23 ++- web/lib/navigation.tsx | 8 +- web/pages/ToolsPage.tsx | 353 ------------------------------------- 10 files changed, 359 insertions(+), 446 deletions(-) create mode 100644 api/lib/functions.ts create mode 100644 api/lib/functions_test.ts delete mode 100644 web/pages/ToolsPage.tsx diff --git a/api/lib/functions.ts b/api/lib/functions.ts new file mode 100644 index 0000000..8aad61e --- /dev/null +++ b/api/lib/functions.ts @@ -0,0 +1,161 @@ +import { batch } from '/api/lib/json_store.ts' +import { join } from '@std/path' +import { ensureDir } from '@std/fs' +import { DeploymentFunction } from '/api/schema.ts' + +// Define the function signatures +export type FunctionContext = { + deploymentUrl: string + projectId: string + variables?: Record +} + +export type ReadTransformer = ( + row: T, + ctx: FunctionContext, +) => T | Promise + +export type ProjectFunctionModule = { + read?: ReadTransformer + config?: { + targets?: string[] + events?: string[] + } +} + +export type LoadedFunction = { + name: string // filename + module: ProjectFunctionModule +} + +// Map +const functionsMap = new Map() +let watcher: Deno.FsWatcher | null = null +const functionsDir = './db/functions' + +export async function init() { + await ensureDir(functionsDir) + await loadAll() + startWatcher() +} + +async function loadAll() { + console.info('Loading project functions...') + for await (const entry of Deno.readDir(functionsDir)) { + if (entry.isDirectory) { + await reloadProjectFunctions(entry.name) + } + } +} + +async function reloadProjectFunctions(slug: string) { + const projectDir = join(functionsDir, slug) + const loaded: LoadedFunction[] = [] + + try { + await batch(5, Deno.readDir(projectDir), async (entry) => { + if (entry.isFile && entry.name.endsWith('.js')) { + const mainFile = join(projectDir, entry.name) + // Build a fresh import URL to bust cache + const importUrl = `file://${await Deno.realPath( + mainFile, + )}?t=${Date.now()}` + try { + const module = await import(importUrl) + // We expect a default export or specific named exports + const fns = module.default + if (fns && typeof fns === 'object') { + loaded.push({ name: entry.name, module: fns }) + } + } catch (e) { + console.error(`Failed to import ${entry.name} for ${slug}:`, e) + } + } + }) + + // Sort by filename to ensure deterministic execution order + loaded.sort((a, b) => a.name.localeCompare(b.name)) + + if (loaded.length > 0) { + functionsMap.set(slug, loaded) + console.info(`Loaded ${loaded.length} functions for project: ${slug}`) + } else { + functionsMap.delete(slug) + } + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) { + console.error(`Failed to load functions for ${slug}:`, err) + } + functionsMap.delete(slug) + } +} + +function startWatcher() { + if (watcher) return + console.info(`Starting function watcher on ${functionsDir}`) + watcher = Deno.watchFs(functionsDir, { recursive: true }) // Process events + ;(async () => { + for await (const event of watcher!) { + if (['modify', 'create', 'remove'].includes(event.kind)) { + for (const path of event.paths) { + if (path.endsWith('.js')) { + const parts = path.split('/') + const fileName = parts.pop() + const slug = parts.pop() + if (fileName && slug) { + await reloadProjectFunctions(slug) + } + } + } + } + } + })() +} + +export function getProjectFunctions( + slug: string, +): LoadedFunction[] | undefined { + return functionsMap.get(slug) +} + +export function stopWatcher() { + if (watcher) { + watcher.close() + watcher = null + } +} + +export async function applyReadTransformers( + data: T, + projectId: string, + deploymentUrl: string, + tableName: string, + projectFunctions?: LoadedFunction[], + configMap?: Map, +): Promise { + if (!projectFunctions || projectFunctions.length === 0) { + return data + } + let currentData = data + for (const { name, module } of projectFunctions) { + if (!module.read) continue + const config = configMap?.get(name) + if (!config) continue + if (module.config?.targets && !module.config.targets.includes(tableName)) { + continue + } + if (module.config?.events && !module.config.events.includes('read')) { + continue + } + + const ctx: FunctionContext = { + deploymentUrl, + projectId, + variables: config.variables || {}, + } + + currentData = await module.read(currentData, ctx) as T + } + + return currentData +} diff --git a/api/lib/functions_test.ts b/api/lib/functions_test.ts new file mode 100644 index 0000000..943d512 --- /dev/null +++ b/api/lib/functions_test.ts @@ -0,0 +1,146 @@ +import { assertEquals } from '@std/assert' +import * as functions from './functions.ts' +import { join } from '@std/path' +import { ensureDir } from '@std/fs' +import { DeploymentFunctionsCollection } from '../schema.ts' + +Deno.test('Functions Module - Pipeline & Config', async () => { + const testSlug = 'test-project-' + Date.now() + const functionsDir = './db/functions' + const projectDir = join(functionsDir, testSlug) + const file1 = join(projectDir, '01-first.js') + const file2 = join(projectDir, '02-second.js') + + try { + await Deno.remove('./db_test/deployment_functions', { recursive: true }) + await ensureDir('./db_test/deployment_functions') + } catch { + // Skipped + } + + await ensureDir(projectDir) + + // Initialize module + await functions.init() + + // Define test row type + type TestRow = { + id: number + step1?: boolean + step2?: boolean + var1?: string + } + + // 1. Create function files + const code1 = ` + export default { + read: (row, ctx) => { + return { ...row, step1: true, var1: ctx.variables.var1 } + } + } + ` + const code2 = ` + export default { + read: (row) => { + return { ...row, step2: true } + } + } + ` + await Deno.writeTextFile(file1, code1) + await Deno.writeTextFile(file2, code2) + + // Give watcher time + await new Promise((r) => setTimeout(r, 1000)) + + // 2. Verify loading and sorting + const loaded = functions.getProjectFunctions(testSlug) + if (!loaded) throw new Error('Functions not loaded') + assertEquals(loaded.length, 2) + assertEquals(loaded[0].name, '01-first.js') + assertEquals(loaded[1].name, '02-second.js') + + // 3. Mock Deployment Config + const deploymentUrl = 'test-pipeline-' + Date.now() + '.com' + + // Config for 01-first.js (Enabled with variables) + await DeploymentFunctionsCollection.insert({ + id: deploymentUrl + ':01-first.js', + deploymentUrl, + functionName: '01-first.js', + enabled: true, + variables: { var1: 'secret-value' }, + }) + + // Config for 02-second.js (Disabled) + await DeploymentFunctionsCollection.insert({ + id: deploymentUrl + ':02-second.js', + deploymentUrl, + functionName: '02-second.js', + enabled: false, + variables: {}, + }) + + // 4. Simulate Pipeline Execution (Manually, echoing sql.ts logic) + // We can't import sql.ts functions easily here without mocking runSQL, + // so we re-implement the pipeline logic to verify the components work. + + let row: TestRow = { id: 1 } + const configs = DeploymentFunctionsCollection.filter((c) => + c.deploymentUrl === deploymentUrl && c.enabled + ) + const configMap = new Map(configs.map((c) => [c.functionName, c])) + + for (const { name, module } of loaded) { + const config = configMap.get(name) + if (!config || !module.read) continue + + const ctx = { + deploymentUrl, + projectId: testSlug, + variables: config.variables || undefined, + } + row = await module.read(row, ctx) as TestRow + } + + const result = row + assertEquals(result.step1, true) + assertEquals(result.var1, 'secret-value') + assertEquals(result.step2, undefined) // Should be skipped + + // 5. Enable second function + await DeploymentFunctionsCollection.update(deploymentUrl + ':02-second.js', { + enabled: true, + }) + + // Rerun pipeline + row = { id: 1 } + const configs2 = DeploymentFunctionsCollection.filter((c) => + c.deploymentUrl === deploymentUrl && c.enabled + ) + const configMap2 = new Map(configs2.map((c) => [c.functionName, c])) + + for (const { name, module } of loaded) { + const config = configMap2.get(name) + if (!config || !module.read) continue + const ctx = { + deploymentUrl, + projectId: testSlug, + variables: config.variables || undefined, + } + row = await module.read(row, ctx) as TestRow + } + + const result2 = row + assertEquals(result2.step1, true) + assertEquals(result2.step2, true) + + // Cleanup + await Deno.remove(projectDir, { recursive: true }) + try { + await Deno.remove('./db_test/deployment_functions', { recursive: true }) + } catch { + // Skipped + } + await new Promise((r) => setTimeout(r, 500)) + functions.stopWatcher() +}) diff --git a/api/lib/json_store.test.ts b/api/lib/json_store.test.ts index 26ec259..7645dbd 100644 --- a/api/lib/json_store.test.ts +++ b/api/lib/json_store.test.ts @@ -1,8 +1,7 @@ // db_test.ts -import { afterEach, beforeEach, describe, it } from '@std/testing/bdd' +import { afterEach, describe, it } from '@std/testing/bdd' import { assert, assertEquals, assertExists, assertRejects } from '@std/assert' import { createCollection } from './json_store.ts' -import { ensureDir } from '@std/fs' type User = { id: number @@ -11,12 +10,8 @@ type User = { age?: number | null } -let dbDir: string - -beforeEach(async () => { - dbDir = './db_test' - await ensureDir(dbDir) -}) +const TEST_COLLECTION = 'users_test' +const dbDir = './db_test/' + TEST_COLLECTION afterEach(async () => { try { @@ -29,7 +24,7 @@ afterEach(async () => { describe('createCollection', () => { it('inserts a record with an auto-generated numeric id', async () => { const users = await createCollection({ - name: 'users', + name: TEST_COLLECTION, primaryKey: 'email', }) @@ -47,7 +42,7 @@ describe('createCollection', () => { it('finds a record by id', async () => { const users = await createCollection({ - name: 'users', + name: TEST_COLLECTION, primaryKey: 'email', }) @@ -63,7 +58,7 @@ describe('createCollection', () => { it('returns null when record not found by id', async () => { const users = await createCollection({ - name: 'users', + name: TEST_COLLECTION, primaryKey: 'id', }) @@ -73,7 +68,7 @@ describe('createCollection', () => { it('updates a record', async () => { const users = await createCollection({ - name: 'users', + name: TEST_COLLECTION, primaryKey: 'id', }) @@ -91,7 +86,7 @@ describe('createCollection', () => { it('deletes a record', async () => { const users = await createCollection({ - name: 'users', + name: TEST_COLLECTION, primaryKey: 'id', }) @@ -109,7 +104,7 @@ describe('createCollection', () => { it('finds records using predicate', async () => { const users = await createCollection({ - name: 'users', + name: TEST_COLLECTION, primaryKey: 'id', }) @@ -138,7 +133,7 @@ describe('createCollection', () => { it('enforces unique key constraint on insert', async () => { const users = await createCollection({ - name: 'users', + name: TEST_COLLECTION, primaryKey: 'email', }) @@ -158,7 +153,7 @@ describe('createCollection', () => { it('returns null/false for update/delete on non-existent id', async () => { const users = await createCollection({ - name: 'users', + name: TEST_COLLECTION, primaryKey: 'id', }) @@ -177,7 +172,7 @@ describe('createCollection', () => { it('handles null/undefined unique fields gracefully', async () => { const users = await createCollection({ - name: 'users', + name: TEST_COLLECTION, primaryKey: 'id', }) @@ -199,7 +194,7 @@ describe('createCollection', () => { it('evicts LRU cache when cacheSize exceeded', async () => { const users = await createCollection({ - name: 'users', + name: TEST_COLLECTION, primaryKey: 'id', }) diff --git a/api/lib/json_store.ts b/api/lib/json_store.ts index 14700c3..53c2701 100644 --- a/api/lib/json_store.ts +++ b/api/lib/json_store.ts @@ -15,7 +15,7 @@ async function atomicWrite(filePath: string, content: string): Promise { await Deno.rename(tmp, filePath) } -const batch = async ( +export const batch = async ( concurrency: number, source: AsyncIterable, handler: (item: T) => Promise, diff --git a/api/routes.ts b/api/routes.ts index bc4850e..407e71a 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -6,8 +6,6 @@ import { DeploymentDef, DeploymentsCollection, ProjectsCollection, - SQLToolDef, - SQLToolsCollection, TeamDef, TeamsCollection, UserDef, @@ -240,54 +238,6 @@ const defs = { output: BOOL('Indicates if the project was deleted'), description: 'Delete a project by ID', }), - 'GET/api/project/tools': route({ - authorize: withUserSession, - fn: (_ctx, { project }) => { - const tools = SQLToolsCollection.filter((t) => t.projectId === project) - return tools - }, - input: OBJ({ project: STR('The ID of the project') }), - output: ARR(SQLToolDef, 'List of SQL tools'), - description: 'Get SQL tools for a project', - }), - 'POST/api/project/tool': route({ - authorize: withAdminSession, - fn: (_ctx, input) => { - const toolId = crypto.randomUUID() - const tool = { ...input, toolId } - SQLToolsCollection.insert(tool) - return tool - }, - input: OBJ({ - projectId: STR('The ID of the project'), - name: STR('The name of the tool'), - targetTables: ARR( - STR('Target table names or *'), - 'List of target tables', - ), - targetColumns: ARR( - STR('Target column names or *'), - 'List of target columns', - ), - triggerEvent: LIST(['BEFORE', 'AFTER'], 'Trigger event: BEFORE or AFTER'), - code: STR('The JS function body'), - enabled: BOOL('Is the tool enabled?'), - }), - output: SQLToolDef, - description: 'Create a new SQL tool', - }), - 'DELETE/api/project/tool': route({ - authorize: withAdminSession, - fn: (_ctx, { id }) => { - const tool = SQLToolsCollection.get(id) - if (!tool) throw respond.NotFound({ message: 'Tool not found' }) - SQLToolsCollection.delete(id) - return true - }, - input: OBJ({ id: STR('The ID of the tool') }), - output: BOOL('Indicates if the tool was deleted'), - description: 'Delete a SQL tool', - }), 'GET/api/project/deployments': route({ authorize: withUserSession, fn: (_ctx, { project }) => { diff --git a/api/schema.ts b/api/schema.ts index 7424e30..935082c 100644 --- a/api/schema.ts +++ b/api/schema.ts @@ -2,7 +2,6 @@ import { ARR, type Asserted, BOOL, - LIST, NUM, OBJ, optional, @@ -94,19 +93,16 @@ export const DatabaseSchemasCollection = await createCollection< 'deploymentUrl' >({ name: 'db_schemas', primaryKey: 'deploymentUrl' }) -export const SQLToolDef = OBJ({ - toolId: STR('The unique identifier for the tool'), - projectId: STR('The ID of the project this tool belongs to'), - name: STR('The name of the tool'), - targetTables: ARR(STR('Target table names or *'), 'List of target tables'), - targetColumns: ARR(STR('Target column names or *'), 'List of target columns'), - triggerEvent: LIST(['BEFORE', 'AFTER'], 'Trigger event: BEFORE or AFTER'), - code: STR('The JS function body'), - enabled: BOOL('Is the tool enabled?'), -}, 'The SQL tool definition') -export type SQLTool = Asserted +export const DeploymentFunctionDef = OBJ({ + id: STR('Unique ID: deploymentUrl + functionName'), + deploymentUrl: STR('Link to deployment'), + functionName: STR('Filename of the function'), + variables: optional(OBJ({}, 'Configuration variables')), + enabled: BOOL('Is the function enabled?'), +}, 'Deployment function configuration') +export type DeploymentFunction = Asserted -export const SQLToolsCollection = await createCollection({ - name: 'sql_tools', - primaryKey: 'toolId', -}) +export const DeploymentFunctionsCollection = await createCollection< + DeploymentFunction, + 'id' +>({ name: 'deployment_functions', primaryKey: 'id' }) diff --git a/api/server.ts b/api/server.ts index 3127dc5..b876ef2 100644 --- a/api/server.ts +++ b/api/server.ts @@ -4,6 +4,9 @@ import { server } from '@01edu/api/server' import { Log } from '@01edu/api/log' import { routeHandler } from '/api/routes.ts' import { PORT } from './lib/env.ts' +import { init } from '/api/lib/functions.ts' + +await init() const fetch = server({ log: console as unknown as Log, routeHandler }) export default { diff --git a/api/sql.ts b/api/sql.ts index 118a8ca..f97a2e8 100644 --- a/api/sql.ts +++ b/api/sql.ts @@ -1,9 +1,14 @@ import { DatabaseSchemasCollection, Deployment, + DeploymentFunctionsCollection, DeploymentsCollection, } from '/api/schema.ts' import { DB_SCHEMA_REFRESH_MS } from '/api/lib/env.ts' +import { + applyReadTransformers, + getProjectFunctions, +} from '/api/lib/functions.ts' export class SQLQueryError extends Error { constructor(message: string, body: string) { @@ -223,6 +228,12 @@ export const fetchTablesData = async ( if (!sqlToken || !sqlEndpoint) { throw Error('Missing SQL endpoint or token') } + const projectFunctions = getProjectFunctions(params.deployment.projectId) + const configs = DeploymentFunctionsCollection.filter((c) => + c.deploymentUrl === params.deployment.url && c.enabled + ) + const configMap = new Map(configs.map((c) => [c.functionName, c])) + const whereClause = constructWhereClause(params, columnsMap) const orderByClause = constructOrderByClause(params, columnsMap) @@ -244,8 +255,18 @@ export const fetchTablesData = async ( `SELECT COUNT(*) as count FROM ${params.table} ${whereClause}` const rows = await runSQL(sqlEndpoint, sqlToken, query) - return { + // Apply read transformer pipeline + const transformedRows = await applyReadTransformers( rows, + params.deployment.projectId, + params.deployment.url, + params.table, + projectFunctions, + configMap, + ) + + return { + rows: transformedRows, totalRows: limit > 0 ? ((await runSQL(sqlEndpoint, sqlToken, countQuery))[0].count) as number : rows.length, diff --git a/web/lib/navigation.tsx b/web/lib/navigation.tsx index 3f42d39..b5bd84e 100644 --- a/web/lib/navigation.tsx +++ b/web/lib/navigation.tsx @@ -1,7 +1,6 @@ -import { HardDrive, ListTodo, Wrench } from 'lucide-preact' +import { HardDrive, ListTodo } from 'lucide-preact' import { SidebarItem } from '../components/SideBar.tsx' import { DeploymentPage } from '../pages/DeploymentPage.tsx' -import { ToolsPage } from '../pages/ToolsPage.tsx' export const sidebarItems: Record = { 'deployment': { @@ -9,10 +8,5 @@ export const sidebarItems: Record = { label: 'Deployment', component: DeploymentPage, }, - 'tools': { - icon: Wrench, - label: 'Tools', - component: ToolsPage, - }, 'tasks': { icon: ListTodo, label: 'Tasks', component: DeploymentPage }, } as const diff --git a/web/pages/ToolsPage.tsx b/web/pages/ToolsPage.tsx deleted file mode 100644 index 7def1dd..0000000 --- a/web/pages/ToolsPage.tsx +++ /dev/null @@ -1,353 +0,0 @@ -import { effect } from '@preact/signals' -import { api } from '../lib/api.ts' -import { - Code, - Database, - Loader2, - Plus, - Terminal, - Trash, - X, -} from 'lucide-preact' -import { DialogModal } from '../components/Dialog.tsx' -import { A, navigate, url } from '@01edu/signal-router' -import type { SQLTool } from '../../api/schema.ts' -import { project } from '../lib/shared.tsx' -import { TargetedEvent } from 'preact' - -const tools = api['GET/api/project/tools'].signal() -const createTool = api['POST/api/project/tool'].signal() -const deleteTool = api['DELETE/api/project/tool'].signal() - -effect(() => { - const { data } = project - if (!data?.slug) return - tools.fetch({ project: data.slug }) -}) - -const PageHeader = ( - { title, desc, actions }: { - title: string - desc: string - actions?: preact.ComponentChildren - }, -) => ( -
-
-

{title}

-

{desc}

-
- {actions} - {createTool.error || deleteTool.error || - tools.error && ( -
- - {createTool.error || deleteTool.error || tools.error} -
- )} -
-) - -const ToolCard = ({ tool }: { tool: SQLTool }) => { - const onDelete = () => { - if (!project.data?.slug) return - deleteTool.fetch({ id: tool.toolId }) - if (!deleteTool.error) { - tools.fetch({ project: project.data.slug }) - } - } - return ( -
-
-
- {tool.name} -
- - {tool.enabled ? 'Active' : 'Disabled'} - - - {tool.triggerEvent} - -
-
-
-
- TABLES - - {tool.targetTables.join(', ')} - -
-
- COLUMNS - - {tool.targetColumns.join(', ')} - -
-
-
-
-          {tool.code}
-          
-
-
-
-
-
- -
-
- ) -} - -const ToolList = () => ( -
-
-

- Configured Tools ({tools.data?.length}) -

-
- {tools.data?.length === 0 - ? ( -
- -

No tools configured.

- - Create your first tool - -
- ) - : ( -
- {tools.data?.map((tool) => ( - - ))} -
- )} -
-) - -const onSubmit = async (e: TargetedEvent) => { - e.preventDefault() - if (!project.data?.slug) return - - const form = e.currentTarget - const formData = new FormData(form) - const name = formData.get('name') as string - const triggerEvent = formData.get('triggerEvent') as 'BEFORE' | 'AFTER' - const targetTables = (formData.get('targetTables') as string).split(',').map(s => s.trim()).filter(Boolean) - const targetColumns = (formData.get('targetColumns') as string).split(',').map(s => s.trim()).filter(Boolean) - const code = formData.get('code') as string - const enabled = formData.get('enabled') === 'on' - - await createTool.fetch({ - projectId: project.data.slug, - name, - triggerEvent, - targetTables, - targetColumns, - code, - enabled - }) - - if (!createTool.error) { - navigate({params: {dialog: null}}) - tools.fetch({project: project.data.slug}) - } -} - -const CreateToolModal = () => ( - -
-
-

- - Create SQL Tool -

-
- -
-
-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
-
- -
- - -
-
- - - -
-
- ) - -const ToolsSidebar = () => { - const activeTab = url.params.tab - if (!activeTab) { - navigate({ params: { tab: 'sql' }, replace: true }) - return null - } - return ( - - ) -} - -export function ToolsPage() { - return ( -
- -
- - New Tool - - } - /> - -
-
- {tools.pending && tools.data?.length === 0 - ? ( -
- -
- ) - : } -
-
- - -
-
- ) -} From 8a8011f2f964480d8b7a1908c2480488fbdb6e3d Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Tue, 24 Feb 2026 16:48:20 +0000 Subject: [PATCH 3/4] feat: Implement deployment function management with dedicated UI and API endpoints. --- .gitignore | 5 +- api/lib/functions_test.ts | 8 +- api/routes.ts | 93 +++++++++++++ api/schema.ts | 4 +- api/sql.ts | 2 +- db/functions/.gitkeep | 0 web/pages/SettingsPage.tsx | 270 ++++++++++++++++++++++++++++++++----- 7 files changed, 342 insertions(+), 40 deletions(-) create mode 100644 db/functions/.gitkeep diff --git a/.gitignore b/.gitignore index 2d1e601..8085d59 100644 --- a/.gitignore +++ b/.gitignore @@ -143,7 +143,10 @@ vite.config.ts.timestamp-* # Vite cache directory .vite -db/ +db/* +!db/functions/* +!db/functions +db/functions/test-* db_test/ .vscode diff --git a/api/lib/functions_test.ts b/api/lib/functions_test.ts index 943d512..8a41a45 100644 --- a/api/lib/functions_test.ts +++ b/api/lib/functions_test.ts @@ -66,7 +66,7 @@ Deno.test('Functions Module - Pipeline & Config', async () => { await DeploymentFunctionsCollection.insert({ id: deploymentUrl + ':01-first.js', deploymentUrl, - functionName: '01-first.js', + name: '01-first.js', enabled: true, variables: { var1: 'secret-value' }, }) @@ -75,7 +75,7 @@ Deno.test('Functions Module - Pipeline & Config', async () => { await DeploymentFunctionsCollection.insert({ id: deploymentUrl + ':02-second.js', deploymentUrl, - functionName: '02-second.js', + name: '02-second.js', enabled: false, variables: {}, }) @@ -88,7 +88,7 @@ Deno.test('Functions Module - Pipeline & Config', async () => { const configs = DeploymentFunctionsCollection.filter((c) => c.deploymentUrl === deploymentUrl && c.enabled ) - const configMap = new Map(configs.map((c) => [c.functionName, c])) + const configMap = new Map(configs.map((c) => [c.name, c])) for (const { name, module } of loaded) { const config = configMap.get(name) @@ -117,7 +117,7 @@ Deno.test('Functions Module - Pipeline & Config', async () => { const configs2 = DeploymentFunctionsCollection.filter((c) => c.deploymentUrl === deploymentUrl && c.enabled ) - const configMap2 = new Map(configs2.map((c) => [c.functionName, c])) + const configMap2 = new Map(configs2.map((c) => [c.name, c])) for (const { name, module } of loaded) { const config = configMap2.get(name) diff --git a/api/routes.ts b/api/routes.ts index 407e71a..73d229f 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -4,6 +4,9 @@ import { handleGoogleCallback, initiateGoogleAuth } from '/api/auth.ts' import { DatabaseSchemasCollection, DeploymentDef, + DeploymentFunction, + DeploymentFunctionDef, + DeploymentFunctionsCollection, DeploymentsCollection, ProjectsCollection, TeamDef, @@ -24,6 +27,7 @@ import { import { decodeSession, decryptMessage, encryptMessage } from '/api/user.ts' import { fetchTablesData, runSQL, SQLQueryError } from '/api/sql.ts' import { Log } from '@01edu/api/log' +import { getProjectFunctions } from './lib/functions.ts' const withUserSession = async ({ cookies }: RequestContext) => { const session = await decodeSession(cookies.session) @@ -571,6 +575,95 @@ const defs = { }), description: 'Run a SQL query against the deployment database', }), + 'GET/api/project/functions': route({ + authorize: withUserSession, + fn: (_ctx, { slug }) => { + const project = ProjectsCollection.get(slug) + if (!project) throw respond.NotFound({ message: 'Project not found' }) + + const loaded = getProjectFunctions(slug) || [] + return loaded.map((f) => ({ name: f.name })) + }, + input: OBJ({ slug: STR('The unique identifier for the project') }), + output: ARR( + OBJ({ name: STR('The name of the function') }), + 'List of available functions for the project', + ), + description: 'Get all available functions for a project', + }), + 'GET/api/deployment/functions': route({ + authorize: withUserSession, + fn: (_ctx, { url }) => { + const dep = DeploymentsCollection.get(url) + if (!dep) throw respond.NotFound({ message: 'Deployment not found' }) + + const configs = DeploymentFunctionsCollection.filter((c) => + c.deploymentUrl === url + ) + + return configs.map((c) => ({ + id: c.id, + name: c.name, + enabled: c.enabled, + variables: c.variables || {}, + })) + }, + input: OBJ({ url: STR('The URL of the deployment') }), + output: ARR( + OBJ({ + id: STR('Unique ID: deploymentUrl + functionName'), + name: STR('The name of the function'), + enabled: BOOL('Is the function enabled?'), + variables: optional(OBJ({}, 'Configuration variables')), + }), + 'List of functions with their deployment configuration', + ), + description: 'Get all functions for a deployment with their configuration', + }), + 'POST/api/deployment/function': route({ + authorize: withAdminSession, + fn: (_ctx, input) => { + const id = input.deploymentUrl + input.name + return DeploymentFunctionsCollection.insert({ + ...input, + id, + enabled: false, + variables: {}, + }) + }, + input: OBJ({ + deploymentUrl: STR('Link to deployment'), + name: STR('Filename of the function'), + }), + output: DeploymentFunctionDef, + description: 'Add a function to a deployment', + }), + 'PUT/api/deployment/function': route({ + authorize: withAdminSession, + fn: (_ctx, input) => { + const { id, enabled, variables } = input + const updates: Partial = {} + if (enabled != null) updates.enabled = enabled + if (variables != null) updates.variables = variables + return DeploymentFunctionsCollection.update(id, updates) + }, + input: OBJ({ + id: STR('The ID of the function'), + enabled: optional(BOOL('Is the function enabled?')), + variables: optional(OBJ({}, 'Configuration variables')), + }), + output: DeploymentFunctionDef, + description: 'Update a deployment function configuration', + }), + 'DELETE/api/deployment/function': route({ + authorize: withAdminSession, + fn: (_ctx, { id }) => DeploymentFunctionsCollection.delete(id), + input: OBJ({ + id: STR('The ID of the function'), + }), + output: BOOL('Indicates if the function was deleted'), + description: 'Delete a function from a deployment', + }), } as const export type RouteDefinitions = typeof defs diff --git a/api/schema.ts b/api/schema.ts index 935082c..70ce4d6 100644 --- a/api/schema.ts +++ b/api/schema.ts @@ -94,9 +94,9 @@ export const DatabaseSchemasCollection = await createCollection< >({ name: 'db_schemas', primaryKey: 'deploymentUrl' }) export const DeploymentFunctionDef = OBJ({ - id: STR('Unique ID: deploymentUrl + functionName'), + id: STR('Unique ID: deploymentUrl + name'), deploymentUrl: STR('Link to deployment'), - functionName: STR('Filename of the function'), + name: STR('Filename of the function'), variables: optional(OBJ({}, 'Configuration variables')), enabled: BOOL('Is the function enabled?'), }, 'Deployment function configuration') diff --git a/api/sql.ts b/api/sql.ts index f97a2e8..7259a2a 100644 --- a/api/sql.ts +++ b/api/sql.ts @@ -232,7 +232,7 @@ export const fetchTablesData = async ( const configs = DeploymentFunctionsCollection.filter((c) => c.deploymentUrl === params.deployment.url && c.enabled ) - const configMap = new Map(configs.map((c) => [c.functionName, c])) + const configMap = new Map(configs.map((c) => [c.name, c])) const whereClause = constructWhereClause(params, columnsMap) const orderByClause = constructOrderByClause(params, columnsMap) diff --git a/db/functions/.gitkeep b/db/functions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/pages/SettingsPage.tsx b/web/pages/SettingsPage.tsx index 2f6a08d..c219b42 100644 --- a/web/pages/SettingsPage.tsx +++ b/web/pages/SettingsPage.tsx @@ -19,17 +19,27 @@ import { import { DialogModal } from '../components/Dialog.tsx' import type { TargetedEvent } from 'preact' import { effect, signal } from '@preact/signals' +import { useEffect, useState } from 'preact/hooks' // API Signals const updateProject = api['PUT/api/project'].signal() const updateDeployment = api['PUT/api/deployment'].signal() const createDeployment = api['POST/api/deployment'].signal() const getDeployment = api['GET/api/deployment'].signal() +const getDeploymentFunctions = api['GET/api/deployment/functions'].signal() +const createDeploymentFunction = api['POST/api/deployment/function'].signal() +const deleteDeploymentFunction = api['DELETE/api/deployment/function'].signal() +const getProjectFunctions = api['GET/api/project/functions'].signal() +const updateDeploymentFunction = api['PUT/api/deployment/function'].signal() const regenToken = api['POST/api/deployment/token/regenerate'].signal() effect(() => { if (url.params.dep) { getDeployment.fetch({ url: url.params.dep }) + getDeploymentFunctions.fetch({ url: url.params.dep }) + } + if (project.data?.slug) { + getProjectFunctions.fetch({ slug: project.data.slug }) } }) @@ -344,22 +354,6 @@ const Accordion = ({ ) } -const ToolCard = ( - { title, desc, empty }: { title: string; desc: string; empty: string }, -) => ( -
-

{title}

-

{desc}

-
{empty}
- - Add {title.split(' ').pop()} - -
-) - const addDeploymentError = signal(null) const handleSubmit = async (e: TargetedEvent) => { e.preventDefault() @@ -422,6 +416,231 @@ const AddDeploymentDialog = () => ( ) +const FunctionItem = ({ + fn, + deploymentUrl, + updating, +}: { + fn: { id: string; name: string; enabled: boolean } + deploymentUrl: string + updating: boolean +}) => ( +
+
+

{fn.name}

+
+
+ + {fn.enabled ? 'Active' : 'Inactive'} + +
+
+
+ { + await updateDeploymentFunction.fetch({ + id: fn.id, + enabled: e.currentTarget.checked, + variables: undefined, + }) + getDeploymentFunctions.fetch({ url: deploymentUrl }) + }} + /> +
+ + + + +
+
+
+) + +const FunctionsSettingsSection = ( + { deploymentUrl }: { deploymentUrl: string }, +) => { + const fns = getDeploymentFunctions.data ?? [] + const loading = getDeploymentFunctions.pending + const updating = !!updateDeploymentFunction.pending + + return ( + + Add + + } + > +
+ {!fns.length && !loading && ( +

+ No functions configured for this deployment. +

+ )} + {fns.map((fn) => ( + + ))} +
+
+ ) +} + +const AddFunctionDialog = () => { + const dep = getDeployment.data + const projectFns = getProjectFunctions.data ?? [] + const existingFns = getDeploymentFunctions.data ?? [] + const availableFns = projectFns.filter( + (pf) => !existingFns.find((ef) => ef.name === pf.name), + ) + + const handleAdd = async (fnName: string) => { + if (!dep) return + try { + await createDeploymentFunction.fetch({ + deploymentUrl: dep.url, + name: fnName, + }) + getDeploymentFunctions.fetch({ url: dep.url }) + navigate({ params: { dialog: null }, replace: true }) + } catch (e) { + console.error(e) + } + } + + return ( + +

Add Function

+
+ {availableFns.length === 0 + ? ( +

+ All project functions are already added. +

+ ) + : ( + availableFns.map((fn) => ( +
+ {fn.name} + +
+ )) + )} +
+ +
+ ) +} +const FunctionConfigDialog = () => { + const fnId = url.params['fn-id'] + const dep = getDeployment.data + const fn = getDeploymentFunctions.data?.find((f) => f.id === fnId) + + const handleSave = async (e: TargetedEvent) => { + e.preventDefault() + if (!fn) return + const json = + (e.currentTarget.elements.namedItem('json') as HTMLTextAreaElement).value + try { + const variables = JSON.parse(json) + await updateDeploymentFunction.fetch({ + id: fn.id, + enabled: fn.enabled, + variables, + }) + getDeploymentFunctions.fetch({ url: dep!.url }) + navigate({ params: { dialog: null, 'fn-id': null }, replace: true }) + } catch (e) { + alert('Invalid JSON: ' + (e as Error).message) + } + } + + if (!fn) return null + + return ( + +

Configure {fn.name}

+

+ {fn.id} +

+ +
+
+