Skip to content

Commit 4470064

Browse files
authored
DT-94: Add tooling system for custom data processing on DB operations #94 (#95)
* feat: implement SQL tool management page and refactor sidebar navigation into a dedicated module. * feat: Implement server-side functions for SQL read transformation and remove the old Tools page. * feat: Implement deployment function management with dedicated UI and API endpoints. * feat: Introduce write transformers to the function API and add database functions for password hashing and tactic content compression. * Remove the deployment functions feature, including its API routes, schema definitions, and associated UI components. * feat: simplify function pipeline by removing dynamic configuration and move sidebar items to shared utility.
1 parent e219d22 commit 4470064

10 files changed

Lines changed: 423 additions & 53 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,10 @@ vite.config.ts.timestamp-*
143143
# Vite cache directory
144144
.vite
145145

146-
db/
146+
db/*
147+
!db/functions/*
148+
!db/functions
149+
db/functions/test-*
147150
db_test/
148151
.vscode
149152

api/lib/functions.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { batch } from '/api/lib/json_store.ts'
2+
import { join } from '@std/path'
3+
import { ensureDir } from '@std/fs'
4+
5+
// Define the function signatures
6+
export type FunctionContext = {
7+
deploymentUrl: string
8+
projectId: string
9+
}
10+
11+
export type ReadTransformer<T = unknown> = (
12+
data: T,
13+
ctx: FunctionContext,
14+
) => T | Promise<T>
15+
16+
export type WriteTransformer<T = unknown> = (
17+
table: string,
18+
data: T,
19+
query: string | undefined,
20+
ctx: FunctionContext,
21+
) => T | Promise<T>
22+
23+
export type ProjectFunctionModule = {
24+
read?: ReadTransformer
25+
write?: WriteTransformer
26+
config?: {
27+
targets?: string[]
28+
}
29+
}
30+
31+
export type LoadedFunction = {
32+
name: string // filename
33+
module: ProjectFunctionModule
34+
}
35+
36+
// Map<projectSlug, List of loaded functions>
37+
const functionsMap = new Map<string, LoadedFunction[]>()
38+
let watcher: Deno.FsWatcher | null = null
39+
const functionsDir = './db/functions'
40+
41+
export async function init() {
42+
await ensureDir(functionsDir)
43+
await loadAll()
44+
startWatcher()
45+
}
46+
47+
async function loadAll() {
48+
console.info('Loading project functions...')
49+
for await (const entry of Deno.readDir(functionsDir)) {
50+
if (entry.isDirectory) {
51+
await reloadProjectFunctions(entry.name)
52+
}
53+
}
54+
}
55+
56+
async function reloadProjectFunctions(slug: string) {
57+
const projectDir = join(functionsDir, slug)
58+
const loaded: LoadedFunction[] = []
59+
60+
try {
61+
await batch(5, Deno.readDir(projectDir), async (entry) => {
62+
if (entry.isFile && entry.name.endsWith('.js')) {
63+
const mainFile = join(projectDir, entry.name)
64+
// Build a fresh import URL to bust cache
65+
const importUrl = `file://${await Deno.realPath(
66+
mainFile,
67+
)}?t=${Date.now()}`
68+
try {
69+
const module = await import(importUrl)
70+
// We expect a default export or specific named exports
71+
const fns = module.default
72+
if (fns && typeof fns === 'object') {
73+
loaded.push({ name: entry.name, module: fns })
74+
}
75+
} catch (e) {
76+
console.error(`Failed to import ${entry.name} for ${slug}:`, e)
77+
}
78+
}
79+
})
80+
81+
// Sort by filename to ensure deterministic execution order
82+
loaded.sort((a, b) => a.name.localeCompare(b.name))
83+
84+
if (loaded.length > 0) {
85+
functionsMap.set(slug, loaded)
86+
console.info(`Loaded ${loaded.length} functions for project: ${slug}`)
87+
} else {
88+
functionsMap.delete(slug)
89+
}
90+
} catch (err) {
91+
if (!(err instanceof Deno.errors.NotFound)) {
92+
console.error(`Failed to load functions for ${slug}:`, err)
93+
}
94+
functionsMap.delete(slug)
95+
}
96+
}
97+
98+
function startWatcher() {
99+
if (watcher) return
100+
console.info(`Starting function watcher on ${functionsDir}`)
101+
watcher = Deno.watchFs(functionsDir, { recursive: true }) // Process events
102+
;(async () => {
103+
for await (const event of watcher!) {
104+
if (!['modify', 'create', 'remove', 'rename'].includes(event.kind)) {
105+
continue
106+
}
107+
for (const path of event.paths) {
108+
if (!path.endsWith('.js')) continue
109+
const parts = path.split('/')
110+
const fileName = parts.pop()
111+
const slug = parts.pop()
112+
if (!fileName || !slug) continue
113+
await reloadProjectFunctions(slug)
114+
}
115+
}
116+
})()
117+
}
118+
119+
export function getProjectFunctions(
120+
slug: string,
121+
): LoadedFunction[] | undefined {
122+
return functionsMap.get(slug)
123+
}
124+
125+
export function stopWatcher() {
126+
if (watcher) {
127+
watcher.close()
128+
watcher = null
129+
}
130+
}
131+
132+
export async function applyReadTransformers<T>(
133+
data: T,
134+
projectId: string,
135+
deploymentUrl: string,
136+
tableName: string,
137+
projectFunctions?: LoadedFunction[],
138+
): Promise<T> {
139+
if (!projectFunctions || projectFunctions.length === 0) {
140+
return data
141+
}
142+
let currentData = data
143+
for (const { module } of projectFunctions) {
144+
if (!module.read) continue
145+
if (module.config?.targets && !module.config.targets.includes(tableName)) {
146+
continue
147+
}
148+
149+
const ctx: FunctionContext = {
150+
deploymentUrl,
151+
projectId,
152+
}
153+
154+
currentData = await module.read(currentData, ctx) as T
155+
}
156+
157+
return currentData
158+
}
159+
160+
export async function applyWriteTransformers<T>(
161+
data: T,
162+
projectId: string,
163+
deploymentUrl: string,
164+
tableName: string,
165+
projectFunctions?: LoadedFunction[],
166+
): Promise<T> {
167+
if (!projectFunctions || projectFunctions.length === 0) {
168+
return data
169+
}
170+
let currentData = data
171+
for (const { module } of projectFunctions) {
172+
if (!module.write) continue
173+
if (module.config?.targets && !module.config.targets.includes(tableName)) {
174+
continue
175+
}
176+
177+
const ctx: FunctionContext = {
178+
deploymentUrl,
179+
projectId,
180+
}
181+
182+
currentData = await module.write(
183+
tableName,
184+
currentData,
185+
undefined,
186+
ctx,
187+
) as T
188+
}
189+
190+
return currentData
191+
}

api/lib/functions_test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { assertEquals } from '@std/assert'
2+
import * as functions from './functions.ts'
3+
import { join } from '@std/path'
4+
import { ensureDir } from '@std/fs'
5+
6+
Deno.test('Functions Module - Pipeline & Config', async () => {
7+
const testSlug = 'test-project-' + Date.now()
8+
const functionsDir = './db/functions'
9+
const projectDir = join(functionsDir, testSlug)
10+
const file1 = join(projectDir, '01-first.js')
11+
const file2 = join(projectDir, '02-second.js')
12+
13+
try {
14+
await Deno.remove('./db_test/deployment_functions', { recursive: true })
15+
await ensureDir('./db_test/deployment_functions')
16+
} catch {
17+
// Skipped
18+
}
19+
20+
await ensureDir(projectDir)
21+
22+
// Initialize module
23+
await functions.init()
24+
25+
// Define test row type
26+
type TestRow = {
27+
id: number
28+
step1?: boolean
29+
step2?: boolean
30+
var1?: string
31+
}
32+
33+
// 1. Create function files
34+
const code1 = `
35+
export default {
36+
read: (row, ctx) => {
37+
return { ...row, step1: true, var1: 'secret-value' }
38+
}
39+
}
40+
`
41+
const code2 = `
42+
export default {
43+
read: (row) => {
44+
return { ...row, step2: true }
45+
}
46+
}
47+
`
48+
await Deno.writeTextFile(file1, code1)
49+
await Deno.writeTextFile(file2, code2)
50+
51+
// Give watcher time
52+
await new Promise((r) => setTimeout(r, 1000))
53+
54+
// 2. Verify loading and sorting
55+
const loaded = functions.getProjectFunctions(testSlug)
56+
if (!loaded) throw new Error('Functions not loaded')
57+
assertEquals(loaded.length, 2)
58+
assertEquals(loaded[0].name, '01-first.js')
59+
assertEquals(loaded[1].name, '02-second.js')
60+
61+
// 3. Mock Deployment Config
62+
const deploymentUrl = 'test-pipeline-' + Date.now() + '.com'
63+
64+
// 4. Simulate Pipeline Execution (Manually, echoing sql.ts logic)
65+
// We can't import sql.ts functions easily here without mocking runSQL,
66+
// so we re-implement the pipeline logic to verify the components work.
67+
68+
let row: TestRow = { id: 1 }
69+
70+
for (const { module } of loaded) {
71+
if (!module.read) continue
72+
73+
const ctx = {
74+
deploymentUrl,
75+
projectId: testSlug,
76+
}
77+
row = await module.read(row, ctx) as TestRow
78+
}
79+
80+
const result = row
81+
assertEquals(result.step1, true)
82+
assertEquals(result.var1, 'secret-value')
83+
84+
// Rerun pipeline
85+
row = { id: 1 }
86+
87+
for (const { module } of loaded) {
88+
if (!module.read) continue
89+
const ctx = {
90+
deploymentUrl,
91+
projectId: testSlug,
92+
}
93+
row = await module.read(row, ctx) as TestRow
94+
}
95+
96+
const result2 = row
97+
assertEquals(result2.step1, true)
98+
assertEquals(result2.step2, true)
99+
100+
// Cleanup
101+
await Deno.remove(projectDir, { recursive: true })
102+
try {
103+
await Deno.remove('./db_test/deployment_functions', { recursive: true })
104+
} catch {
105+
// Skipped
106+
}
107+
await new Promise((r) => setTimeout(r, 500))
108+
functions.stopWatcher()
109+
})

0 commit comments

Comments
 (0)