diff --git a/cli/src/commands/everywhere/view.ts b/cli/src/commands/everywhere/view.ts index 3a48dc1..a0e48ad 100644 --- a/cli/src/commands/everywhere/view.ts +++ b/cli/src/commands/everywhere/view.ts @@ -34,11 +34,16 @@ export default class ViewCommand extends EverywhereBaseCommand { this.log(`Starting viewer on port ${flags.port}...`); // Vite is ESM-only; use dynamic import from this CJS module. - const { createServer } = await import('vite'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vite: any = await import('vite'); - const server = await createServer({ + // Data service plugin — serves /api/data/graphql backed by .data/ files. + const { dataServicePlugin } = await import('../../data/vite-data-plugin.js'); + + const server = await vite.createServer({ root: viewerDir, configFile: false, + plugins: [dataServicePlugin(pluginDir)], server: { port: flags.port, open: flags.open, diff --git a/cli/src/data/graphql-handler.ts b/cli/src/data/graphql-handler.ts new file mode 100644 index 0000000..28cda28 --- /dev/null +++ b/cli/src/data/graphql-handler.ts @@ -0,0 +1,45 @@ +import { LocalStore } from './local-store.js'; + +interface GraphQLRequest { + query: string; + variables: Record; +} + +type GraphQLResponse = { data: unknown } | { error: string }; + +export async function handleGraphQL( + dataDir: string, + request: GraphQLRequest +): Promise { + const store = new LocalStore(dataDir); + const { query, variables } = request; + const type = variables.type as string; + + try { + switch (query) { + case 'find': + return { + data: await store.find(type, variables.filter as Record | undefined), + }; + case 'findOne': + return { data: await store.findOne(type, variables.id as string) }; + case 'create': + return { data: await store.create(type, variables.data as Record) }; + case 'update': + return { + data: await store.update( + type, + variables.id as string, + variables.data as Record + ), + }; + case 'delete': + await store.remove(type, variables.id as string); + return { data: null }; + default: + return { error: `Unknown operation: ${query}` }; + } + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/cli/src/data/local-store.ts b/cli/src/data/local-store.ts new file mode 100644 index 0000000..d108114 --- /dev/null +++ b/cli/src/data/local-store.ts @@ -0,0 +1,60 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; + +type DataRecord = Record & { id: string }; + +export class LocalStore { + constructor(private readonly dataDir: string) {} + + async find(type: string, filter?: Record): Promise { + const records = this.readFile(type); + if (!filter) return records; + + return records.filter((record) => + Object.entries(filter).every(([key, value]) => record[key] === value) + ); + } + + async findOne(type: string, id: string): Promise { + const records = this.readFile(type); + return records.find((r) => r.id === id) ?? null; + } + + async create(type: string, data: Record): Promise { + const records = this.readFile(type); + const record: DataRecord = { id: crypto.randomUUID(), ...data }; + records.push(record); + this.writeFile(type, records); + return record; + } + + async update(type: string, id: string, data: Record): Promise { + const records = this.readFile(type); + const index = records.findIndex((r) => r.id === id); + if (index === -1) throw new Error('Record not found'); + + const updated = { ...records[index], ...data, id } as DataRecord; + records[index] = updated; + this.writeFile(type, records); + return updated; + } + + async remove(type: string, id: string): Promise { + const records = this.readFile(type); + const filtered = records.filter((r) => r.id !== id); + this.writeFile(type, filtered); + } + + private readFile(type: string): DataRecord[] { + const filePath = path.join(this.dataDir, `${type}.json`); + if (!fs.existsSync(filePath)) return []; + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as DataRecord[]; + } + + private writeFile(type: string, records: DataRecord[]): void { + fs.mkdirSync(this.dataDir, { recursive: true }); + const filePath = path.join(this.dataDir, `${type}.json`); + fs.writeFileSync(filePath, JSON.stringify(records, null, 2) + '\n'); + } +} diff --git a/cli/src/data/vite-data-plugin.ts b/cli/src/data/vite-data-plugin.ts new file mode 100644 index 0000000..a0f0105 --- /dev/null +++ b/cli/src/data/vite-data-plugin.ts @@ -0,0 +1,45 @@ +import * as path from 'node:path'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { handleGraphQL } from './graphql-handler.js'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type VitePlugin = any; + +export function dataServicePlugin(pluginDir: string): VitePlugin { + const dataDir = path.join(pluginDir, '.data'); + + return { + name: 'workday-everywhere-data', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + configureServer(server: { middlewares: { use: (...args: any[]) => void } }) { + server.middlewares.use( + '/api/data/graphql', + async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + const body = await new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString())); + }); + + const request = JSON.parse(body) as { + query: string; + variables: Record; + }; + const result = await handleGraphQL(dataDir, request); + + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }); + res.end(JSON.stringify(result)); + } + ); + }, + }; +} diff --git a/cli/tests/data/graphql-handler.test.ts b/cli/tests/data/graphql-handler.test.ts new file mode 100644 index 0000000..702a4ca --- /dev/null +++ b/cli/tests/data/graphql-handler.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { handleGraphQL } from '../../src/data/graphql-handler.js'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'we-handler-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +async function query(op: string, variables: Record = {}) { + return handleGraphQL(tmpDir, { query: op, variables }); +} + +describe('handleGraphQL()', () => { + describe('find', () => { + it('returns an empty array when no data exists', async () => { + const result = await query('find', { type: 'Employee' }); + + expect(result).toEqual({ data: [] }); + }); + + it('returns all records for a type', async () => { + const records = [{ id: '1', name: 'Alice' }]; + fs.writeFileSync(path.join(tmpDir, 'Employee.json'), JSON.stringify(records)); + + const result = await query('find', { type: 'Employee' }); + + expect(result).toEqual({ data: records }); + }); + }); + + describe('findOne', () => { + it('returns a single record by id', async () => { + const records = [{ id: '1', name: 'Alice' }]; + fs.writeFileSync(path.join(tmpDir, 'Employee.json'), JSON.stringify(records)); + + const result = await query('findOne', { type: 'Employee', id: '1' }); + + expect(result).toEqual({ data: records[0] }); + }); + + it('returns null when the record does not exist', async () => { + const result = await query('findOne', { type: 'Employee', id: '999' }); + + expect(result).toEqual({ data: null }); + }); + }); + + describe('create', () => { + it('returns the created record with an id', async () => { + const result = await query('create', { type: 'Employee', data: { name: 'Alice' } }); + + expect((result as { data: { id: string } }).data.id).toBeDefined(); + }); + + it('persists the record', async () => { + await query('create', { type: 'Employee', data: { name: 'Alice' } }); + + const data = JSON.parse(fs.readFileSync(path.join(tmpDir, 'Employee.json'), 'utf-8')); + expect(data).toHaveLength(1); + }); + }); + + describe('update', () => { + it('returns the updated record', async () => { + fs.writeFileSync( + path.join(tmpDir, 'Employee.json'), + JSON.stringify([{ id: '1', name: 'Alice' }]) + ); + + const result = await query('update', { + type: 'Employee', + id: '1', + data: { name: 'Alice Updated' }, + }); + + expect((result as { data: { name: string } }).data.name).toBe('Alice Updated'); + }); + }); + + describe('delete', () => { + it('removes the record', async () => { + fs.writeFileSync( + path.join(tmpDir, 'Employee.json'), + JSON.stringify([{ id: '1', name: 'Alice' }]) + ); + + await query('delete', { type: 'Employee', id: '1' }); + + const data = JSON.parse(fs.readFileSync(path.join(tmpDir, 'Employee.json'), 'utf-8')); + expect(data).toHaveLength(0); + }); + }); + + describe('unknown operation', () => { + it('returns an error', async () => { + const result = await query('unknown', { type: 'Employee' }); + + expect(result).toEqual({ error: 'Unknown operation: unknown' }); + }); + }); +}); diff --git a/cli/tests/data/local-store.test.ts b/cli/tests/data/local-store.test.ts new file mode 100644 index 0000000..74bb3d2 --- /dev/null +++ b/cli/tests/data/local-store.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { LocalStore } from '../../src/data/local-store.js'; + +let tmpDir: string; +let store: LocalStore; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'we-store-')); + store = new LocalStore(tmpDir); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('LocalStore', () => { + describe('find()', () => { + it('returns an empty array when no data file exists', async () => { + const result = await store.find('Employee'); + + expect(result).toEqual([]); + }); + + it('returns all records from an existing data file', async () => { + const records = [{ id: '1', name: 'Alice' }]; + fs.writeFileSync(path.join(tmpDir, 'Employee.json'), JSON.stringify(records)); + + const result = await store.find('Employee'); + + expect(result).toEqual(records); + }); + + describe('with a filter', () => { + it('returns only matching records', async () => { + const records = [ + { id: '1', name: 'Alice', dept: 'Eng' }, + { id: '2', name: 'Bob', dept: 'Sales' }, + ]; + fs.writeFileSync(path.join(tmpDir, 'Employee.json'), JSON.stringify(records)); + + const result = await store.find('Employee', { dept: 'Eng' }); + + expect(result).toEqual([records[0]]); + }); + }); + }); + + describe('findOne()', () => { + it('returns null when the record does not exist', async () => { + const result = await store.findOne('Employee', '999'); + + expect(result).toBeNull(); + }); + + it('returns the matching record by id', async () => { + const records = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ]; + fs.writeFileSync(path.join(tmpDir, 'Employee.json'), JSON.stringify(records)); + + const result = await store.findOne('Employee', '2'); + + expect(result).toEqual({ id: '2', name: 'Bob' }); + }); + }); + + describe('create()', () => { + it('returns a record with a generated id', async () => { + const result = await store.create('Employee', { name: 'Alice' }); + + expect(result.id).toBeDefined(); + }); + + it('persists the record to disk', async () => { + await store.create('Employee', { name: 'Alice' }); + + const data = JSON.parse(fs.readFileSync(path.join(tmpDir, 'Employee.json'), 'utf-8')); + expect(data).toHaveLength(1); + }); + + it('creates the data directory if it does not exist', async () => { + const nestedDir = path.join(tmpDir, 'sub', 'dir'); + const nestedStore = new LocalStore(nestedDir); + + await nestedStore.create('Employee', { name: 'Alice' }); + + expect(fs.existsSync(path.join(nestedDir, 'Employee.json'))).toBe(true); + }); + }); + + describe('update()', () => { + it('merges the input into the existing record', async () => { + const records = [{ id: '1', name: 'Alice', dept: 'Eng' }]; + fs.writeFileSync(path.join(tmpDir, 'Employee.json'), JSON.stringify(records)); + + const result = await store.update('Employee', '1', { name: 'Alice Updated' }); + + expect(result.name).toBe('Alice Updated'); + }); + + it('preserves fields not in the input', async () => { + const records = [{ id: '1', name: 'Alice', dept: 'Eng' }]; + fs.writeFileSync(path.join(tmpDir, 'Employee.json'), JSON.stringify(records)); + + const result = await store.update('Employee', '1', { name: 'Alice Updated' }); + + expect(result.dept).toBe('Eng'); + }); + + it('persists the update to disk', async () => { + const records = [{ id: '1', name: 'Alice' }]; + fs.writeFileSync(path.join(tmpDir, 'Employee.json'), JSON.stringify(records)); + + await store.update('Employee', '1', { name: 'Alice Updated' }); + + const data = JSON.parse(fs.readFileSync(path.join(tmpDir, 'Employee.json'), 'utf-8')); + expect(data[0].name).toBe('Alice Updated'); + }); + + describe('when the record does not exist', () => { + it('throws an error', async () => { + await expect(store.update('Employee', '999', { name: 'X' })).rejects.toThrow( + 'Record not found' + ); + }); + }); + }); + + describe('remove()', () => { + it('removes the record from disk', async () => { + const records = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ]; + fs.writeFileSync(path.join(tmpDir, 'Employee.json'), JSON.stringify(records)); + + await store.remove('Employee', '1'); + + const data = JSON.parse(fs.readFileSync(path.join(tmpDir, 'Employee.json'), 'utf-8')); + expect(data).toHaveLength(1); + }); + + it('keeps other records intact', async () => { + const records = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ]; + fs.writeFileSync(path.join(tmpDir, 'Employee.json'), JSON.stringify(records)); + + await store.remove('Employee', '1'); + + const data = JSON.parse(fs.readFileSync(path.join(tmpDir, 'Employee.json'), 'utf-8')); + expect(data[0].id).toBe('2'); + }); + }); +});