Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions cli/src/commands/everywhere/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions cli/src/data/graphql-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { LocalStore } from './local-store.js';

interface GraphQLRequest {
query: string;
variables: Record<string, unknown>;
}

type GraphQLResponse = { data: unknown } | { error: string };

export async function handleGraphQL(
dataDir: string,
request: GraphQLRequest
): Promise<GraphQLResponse> {
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<string, unknown> | 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<string, unknown>) };
case 'update':
return {
data: await store.update(
type,
variables.id as string,
variables.data as Record<string, unknown>
),
};
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) };
}
}
60 changes: 60 additions & 0 deletions cli/src/data/local-store.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> & { id: string };

export class LocalStore {
constructor(private readonly dataDir: string) {}

async find(type: string, filter?: Record<string, unknown>): Promise<DataRecord[]> {
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<DataRecord | null> {
const records = this.readFile(type);
return records.find((r) => r.id === id) ?? null;
}

async create(type: string, data: Record<string, unknown>): Promise<DataRecord> {
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<string, unknown>): Promise<DataRecord> {
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<void> {
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');
}
}
45 changes: 45 additions & 0 deletions cli/src/data/vite-data-plugin.ts
Original file line number Diff line number Diff line change
@@ -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<string>((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<string, unknown>;
};
const result = await handleGraphQL(dataDir, request);

res.writeHead(200, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
});
res.end(JSON.stringify(result));
}
);
},
};
}
109 changes: 109 additions & 0 deletions cli/tests/data/graphql-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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' });
});
});
});
Loading
Loading