Skip to content

Commit b15c7d4

Browse files
committed
feat: database modernization (web worker + zod)
1 parent bb4e1ce commit b15c7d4

File tree

5 files changed

+297
-28
lines changed

5 files changed

+297
-28
lines changed

packages/phoenix-event-display/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
"jszip": "^3.10.1",
4747
"lodash": "^4.17.21",
4848
"stats-js": "^1.0.1",
49-
"three": "~0.178.0"
49+
"three": "~0.178.0",
50+
"zod": "^4.3.5"
5051
},
5152
"devDependencies": {
5253
"@babel/helper-string-parser": "^7.27.1",
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { z } from 'zod';
2+
3+
/**
4+
* Validates the structure of the 'Types' object in CMS event data.
5+
* The keys are collection names, and the values are arrays of attribute definitions.
6+
* Each attribute definition is an array where the first element is the attribute name.
7+
*/
8+
export const CMSTypesSchema = z.record(
9+
z.string(),
10+
z.array(
11+
z.tuple([z.string()]).rest(z.union([z.string(), z.number()]))
12+
)
13+
);
14+
15+
/**
16+
* Validates the structure of the 'Collections' object in CMS event data.
17+
* The keys are collection names, and the values are arrays of objects (rows).
18+
* Each row is an array of values corresponding to the attributes defined in 'Types'.
19+
*/
20+
export const CMSCollectionsSchema = z.record(
21+
z.string(),
22+
z.array(z.array(z.union([z.number(), z.string()])))
23+
);
24+
25+
/**
26+
* Validates a single item in an Association.
27+
* Typically a tuple of [unknown, index].
28+
*/
29+
export const CMSAssociationItemSchema = z.array(z.tuple([z.number(), z.number()]));
30+
31+
/**
32+
* Validates the structure of the 'Associations' object in CMS event data.
33+
*/
34+
export const CMSAssociationsSchema = z.record(
35+
z.string(),
36+
z.array(CMSAssociationItemSchema)
37+
);
38+
39+
/**
40+
* Main schema for a CMS Event.
41+
*/
42+
export const CMSEventSchema = z.object({
43+
Types: CMSTypesSchema,
44+
Collections: CMSCollectionsSchema,
45+
Associations: CMSAssociationsSchema.optional(),
46+
}).passthrough(); // Allow other properties like 'run number' etc if they exist at top level (though usually they are extracted)
47+
48+
export type CMSEvent = z.infer<typeof CMSEventSchema>;

packages/phoenix-event-display/src/loaders/cms-loader.ts

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,18 @@ export class CMSLoader extends PhoenixLoader {
1111
private data: any;
1212
/** Scale factor for resizing geometry to fit Phoenix event display. */
1313
private geometryScale: number = 1000;
14+
/** Web Worker for off-main-thread parsing. */
15+
private worker: Worker;
1416

1517
/**
1618
* Constructor for the CMS loader.
1719
*/
1820
constructor() {
1921
super();
2022
this.data = {};
23+
// Initialize Web Worker
24+
// Note: This relies on bundler support (Webpack 5+) for new Worker(new URL(...))
25+
this.worker = new Worker(new URL('../workers/cms-loader.worker', import.meta.url));
2126
}
2227

2328
/**
@@ -50,44 +55,45 @@ export class CMSLoader extends PhoenixLoader {
5055
this.loadingManager.addLoadableItem('ig_archive');
5156
const igArchive = new JSZip();
5257
const eventsDataInIg: any[] = [];
58+
5359
const readArchive = (res: File | ArrayBuffer) => {
54-
igArchive.loadAsync(res).then(() => {
60+
igArchive.loadAsync(res).then(async () => {
5561
let allFilesPath = Object.keys(igArchive.files);
5662
// If the event path or name is given then filter all data to get the required events
5763
if (eventPathName) {
5864
allFilesPath = allFilesPath.filter((filePath) =>
5965
filePath.includes(eventPathName),
6066
);
6167
}
62-
let i = 1;
68+
69+
// We'll process files sequentially or in parallel?
70+
// Parallel could spawn too many workers messages. But `async` loop is fine.
71+
let processedCount = 0;
72+
const totalFiles = allFilesPath.length;
73+
74+
if (totalFiles === 0) {
75+
this.loadingManager.itemLoaded('ig_archive');
76+
return;
77+
}
78+
6379
for (const filePathInIg of allFilesPath) {
6480
// If the files are in the "Events" folder then process them.
6581
if (filePathInIg.toLowerCase().startsWith('events')) {
66-
igArchive
67-
?.file(filePathInIg)!
68-
.async('string')
69-
.then((singleEvent: string) => {
70-
// The data has some inconsistencies which need to be removed to properly parse JSON
71-
singleEvent = singleEvent
72-
.replace(/'/g, '"')
73-
.replace(/\(/g, '[')
74-
.replace(/\)/g, ']')
75-
.replace(/nan/g, '0');
76-
const eventJSON = JSON.parse(singleEvent);
77-
eventJSON.eventPath = filePathInIg;
78-
eventsDataInIg.push(eventJSON);
79-
if (i === allFilesPath.length) {
80-
onFileRead(eventsDataInIg);
81-
this.loadingManager.itemLoaded('ig_archive');
82-
}
83-
i++;
84-
});
85-
} else {
86-
if (i === allFilesPath.length) {
87-
onFileRead(eventsDataInIg);
88-
this.loadingManager.itemLoaded('ig_archive');
82+
try {
83+
const singleEvent = await igArchive.file(filePathInIg)!.async('string');
84+
// Use Web Worker to parse
85+
const eventJSON = await this.parseWithWorker(singleEvent, filePathInIg);
86+
eventJSON.eventPath = filePathInIg;
87+
eventsDataInIg.push(eventJSON);
88+
} catch (error) {
89+
console.error(`Error parsing event ${filePathInIg}:`, error);
8990
}
90-
i++;
91+
}
92+
93+
processedCount++;
94+
if (processedCount === totalFiles) {
95+
onFileRead(eventsDataInIg);
96+
this.loadingManager.itemLoaded('ig_archive');
9197
}
9298
}
9399
});
@@ -104,6 +110,26 @@ export class CMSLoader extends PhoenixLoader {
104110
}
105111
}
106112

113+
/**
114+
* Helper to send data to worker and await response.
115+
*/
116+
private parseWithWorker(data: string, id: string): Promise<any> {
117+
return new Promise((resolve, reject) => {
118+
const handler = (event: MessageEvent) => {
119+
if (event.data.id === id) {
120+
this.worker.removeEventListener('message', handler);
121+
if (event.data.type === 'parseCMSResult') {
122+
resolve(event.data.data);
123+
} else {
124+
reject(event.data.error);
125+
}
126+
}
127+
};
128+
this.worker.addEventListener('message', handler);
129+
this.worker.postMessage({ type: 'parseCMS', data, id });
130+
});
131+
}
132+
107133
/**
108134
* Load event data from an ".ig" archive.
109135
* @param filePath Path to the ".ig" archive file.
@@ -118,7 +144,11 @@ export class CMSLoader extends PhoenixLoader {
118144
this.readIgArchive(
119145
filePath,
120146
(allEvents: any[]) => {
121-
onEventRead(allEvents[0]);
147+
if (allEvents.length > 0) {
148+
onEventRead(allEvents[0]);
149+
} else {
150+
console.warn('No events found in archive');
151+
}
122152
},
123153
eventPathName,
124154
);
@@ -573,3 +603,4 @@ export class CMSLoader extends PhoenixLoader {
573603
return metadata;
574604
}
575605
}
606+
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import { CMSLoader } from '../../loaders/cms-loader';
5+
6+
// Mock dependencies BEFORE imports
7+
jest.mock('../../loaders/phoenix-loader', () => {
8+
return {
9+
PhoenixLoader: class MockPhoenixLoader {
10+
loadingManager = {
11+
addLoadableItem: jest.fn(),
12+
itemLoaded: jest.fn()
13+
};
14+
loadObjectTypes() { }
15+
getEventMetadata() { return []; }
16+
addObjectType() { }
17+
}
18+
};
19+
});
20+
21+
jest.mock('three', () => ({
22+
Vector3: class { constructor(x, y, z) { } },
23+
QuadraticBezierCurve3: class { constructor() { } getPoints() { return [] } },
24+
Group: class { }
25+
}));
26+
27+
jest.mock('../../loaders/objects/cms-objects', () => ({
28+
CMSObjects: {
29+
getMuonChamber: jest.fn()
30+
}
31+
}));
32+
33+
const mockJSZipInstance = {
34+
loadAsync: jest.fn().mockReturnThis(),
35+
files: {},
36+
file: jest.fn()
37+
};
38+
jest.mock('jszip', () => {
39+
return jest.fn(() => mockJSZipInstance);
40+
});
41+
42+
43+
// Mock Worker globally
44+
const mockWorker = {
45+
postMessage: jest.fn(),
46+
addEventListener: jest.fn(),
47+
removeEventListener: jest.fn(),
48+
terminate: jest.fn(),
49+
};
50+
51+
global.Worker = jest.fn(() => mockWorker) as any;
52+
global.URL = jest.fn() as any;
53+
54+
55+
describe('CMSLoader Web Worker Integration', () => {
56+
let cmsLoader: CMSLoader;
57+
58+
beforeEach(() => {
59+
jest.clearAllMocks();
60+
// Reset JSZip mock state
61+
mockJSZipInstance.loadAsync.mockResolvedValue({});
62+
mockJSZipInstance.files = {};
63+
64+
cmsLoader = new CMSLoader();
65+
});
66+
67+
it('should initialize Web Worker in constructor', () => {
68+
expect(global.Worker).toHaveBeenCalledTimes(1);
69+
});
70+
71+
it('should use worker for parsing when reading ig archive', async () => {
72+
// Setup mock data
73+
const eventData = JSON.stringify({ Collections: {}, Types: {} });
74+
const mockFile = {
75+
async: jest.fn().mockResolvedValue(eventData)
76+
};
77+
mockJSZipInstance.files = {
78+
'Events/Run_1/Event_1': mockFile
79+
};
80+
mockJSZipInstance.file.mockReturnValue(mockFile);
81+
82+
// Access private method helper via type casting or invoke public method
83+
const onRead = jest.fn();
84+
85+
// This is async internally but returns void. We need to wait for the async operations.
86+
// Since we can't easily await the internal promise chain of readIgArchive without refactoring,
87+
// we will test the `parseWithWorker` method directly if possible, or use timers.
88+
// OR we can spy on `parseWithWorker` if we cast to any.
89+
90+
const parseWithWorkerSpy = jest.spyOn(cmsLoader as any, 'parseWithWorker');
91+
parseWithWorkerSpy.mockResolvedValue({ eventPath: 'Events/Run_1/Event_1', successful: true });
92+
93+
cmsLoader.readIgArchive('dummy.ig', onRead);
94+
95+
// Wait for promises to resolve
96+
await new Promise(resolve => setTimeout(resolve, 100));
97+
98+
expect(mockJSZipInstance.loadAsync).toHaveBeenCalled();
99+
expect(parseWithWorkerSpy).toHaveBeenCalledWith(eventData, 'Events/Run_1/Event_1');
100+
expect(onRead).toHaveBeenCalledWith(expect.arrayContaining([{ eventPath: 'Events/Run_1/Event_1', successful: true }]));
101+
});
102+
103+
it('should handle worker messages correctly in parseWithWorker', async () => {
104+
const promise = (cmsLoader as any).parseWithWorker('{}', 'test-id');
105+
106+
expect(mockWorker.postMessage).toHaveBeenCalledWith({
107+
type: 'parseCMS',
108+
data: '{}',
109+
id: 'test-id'
110+
});
111+
112+
// Simulate successful response
113+
const messageHandler = (mockWorker.addEventListener as jest.Mock).mock.calls[0][1];
114+
messageHandler({
115+
data: {
116+
type: 'parseCMSResult',
117+
id: 'test-id',
118+
data: { result: 'ok' }
119+
}
120+
});
121+
122+
const result = await promise;
123+
expect(result).toEqual({ result: 'ok' });
124+
});
125+
126+
it('should reject when worker returns error', async () => {
127+
const promise = (cmsLoader as any).parseWithWorker('bad-json', 'error-id');
128+
129+
const messageHandler = (mockWorker.addEventListener as jest.Mock).mock.calls[0][1];
130+
messageHandler({
131+
data: {
132+
type: 'parseCMSError',
133+
id: 'error-id',
134+
error: 'Invalid JSON'
135+
}
136+
});
137+
138+
await expect(promise).rejects.toEqual('Invalid JSON');
139+
});
140+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// <reference lib="webworker" />
2+
import { CMSEventSchema } from '../helpers/validation-schemas';
3+
4+
const ctx: Worker = self as any;
5+
6+
ctx.addEventListener('message', (event) => {
7+
const { data, type, id } = event.data;
8+
9+
if (type === 'parseCMS') {
10+
try {
11+
// 1. Clean the input string (logic from CMSLoader.ts)
12+
let singleEvent = data;
13+
singleEvent = singleEvent
14+
.replace(/'/g, '"')
15+
.replace(/\(/g, '[')
16+
.replace(/\)/g, ']')
17+
.replace(/nan/g, '0');
18+
19+
// 2. Parse JSON
20+
const eventJSON = JSON.parse(singleEvent);
21+
22+
// 3. Validation
23+
const validationResult = CMSEventSchema.safeParse(eventJSON);
24+
25+
if (validationResult.success) {
26+
ctx.postMessage({
27+
type: 'parseCMSResult',
28+
id, // Pass back ID to correlate response
29+
data: validationResult.data
30+
});
31+
} else {
32+
ctx.postMessage({
33+
type: 'parseCMSError',
34+
id,
35+
error: validationResult.error
36+
});
37+
}
38+
39+
} catch (error) {
40+
ctx.postMessage({
41+
type: 'parseCMSError',
42+
id,
43+
error: error instanceof Error ? error.message : String(error)
44+
});
45+
}
46+
}
47+
});
48+
49+
export default null as any;

0 commit comments

Comments
 (0)