cogsbox-sync 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,488 @@
1
+ // schema-processor/server.js
2
+ const express = require('express');
3
+ const fs = require('fs');
4
+
5
+ const app = express();
6
+
7
+ app.use(express.json({ limit: '10mb' }));
8
+
9
+ // In-memory cache for schemas per tenant
10
+ const schemaCache = new Map();
11
+
12
+ // Health check endpoint
13
+ app.get('/health', (req, res) => {
14
+ res.json({
15
+ status: 'healthy',
16
+ timestamp: new Date().toISOString(),
17
+ cachedSchemas: Array.from(schemaCache.keys())
18
+ });
19
+ });
20
+
21
+ // Utility function to safely import schema module
22
+ async function importSchemaModule(filePath) {
23
+ try {
24
+ // Clear Node.js module cache to ensure fresh import
25
+ delete require.cache[filePath];
26
+
27
+ const schemaModule = await import(`file://${filePath}?t=${Date.now()}`);
28
+
29
+ if (!schemaModule.syncSchema) {
30
+ throw new Error('syncSchema not found in module');
31
+ }
32
+
33
+ return schemaModule;
34
+ } catch (error) {
35
+ console.error(`Failed to import schema module from ${filePath}:`, error.message);
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ // Load schema for a tenant and cache it
41
+ async function loadSchemaForTenant(tenantId) {
42
+ console.log(`[loadSchemaForTenant] Starting load for tenant ${tenantId}`);
43
+
44
+ if (schemaCache.has(tenantId)) {
45
+ console.log(`[loadSchemaForTenant] Schema already cached for tenant ${tenantId}`);
46
+ return schemaCache.get(tenantId);
47
+ }
48
+
49
+ // Load from file system cache first
50
+ const cacheFile = `/tmp/schema-${tenantId}.mjs`;
51
+ console.log(`[loadSchemaForTenant] Checking cache file: ${cacheFile}`);
52
+
53
+ if (fs.existsSync(cacheFile)) {
54
+ try {
55
+ console.log(`[loadSchemaForTenant] Cache file exists, importing...`);
56
+ const schemaModule = await importSchemaModule(cacheFile);
57
+
58
+ schemaCache.set(tenantId, schemaModule.syncSchema);
59
+ console.log(`[loadSchemaForTenant] Schema cached successfully for tenant ${tenantId}`);
60
+ return schemaModule.syncSchema;
61
+ } catch (error) {
62
+ console.error(`[loadSchemaForTenant] Cache file import failed:`, error.message);
63
+ console.log('Cache file invalid, will reload:', error.message);
64
+ try {
65
+ fs.unlinkSync(cacheFile);
66
+ console.log(`[loadSchemaForTenant] Removed invalid cache file`);
67
+ } catch (unlinkError) {
68
+ console.error(`[loadSchemaForTenant] Failed to remove cache file:`, unlinkError.message);
69
+ }
70
+ }
71
+ } else {
72
+ console.log(`[loadSchemaForTenant] Cache file does not exist`);
73
+ }
74
+
75
+ console.log(`[loadSchemaForTenant] No schema available for tenant ${tenantId}`);
76
+ return null;
77
+ }
78
+
79
+ // GET endpoint to check schema status
80
+ app.get('/load-schema', async (req, res) => {
81
+ try {
82
+ const { tenantId } = req.query;
83
+
84
+ if (!tenantId) {
85
+ return res.status(400).json({ error: 'tenantId is required' });
86
+ }
87
+
88
+ const schema = await loadSchemaForTenant(tenantId);
89
+
90
+ // Handle both schema formats - direct schemas or nested under 'schemas'
91
+ let schemaKeys = [];
92
+ if (schema) {
93
+ if (schema.schemas) {
94
+ schemaKeys = Object.keys(schema.schemas);
95
+ } else {
96
+ schemaKeys = Object.keys(schema);
97
+ }
98
+ }
99
+
100
+ res.json({
101
+ hasSchema: !!schema,
102
+ schemaKeys: schemaKeys
103
+ });
104
+ } catch (error) {
105
+ console.error('Error in GET /load-schema:', error);
106
+ res.status(500).json({ error: error.message });
107
+ }
108
+ });
109
+
110
+ // POST endpoint to load/update schema
111
+ app.post('/load-schema', async (req, res) => {
112
+ try {
113
+ const { tenantId } = req.query;
114
+ const { bundledCode } = req.body;
115
+
116
+ if (!tenantId) {
117
+ return res.status(400).json({ error: 'tenantId is required' });
118
+ }
119
+
120
+ if (bundledCode) {
121
+ // Save and cache new schema
122
+ const cacheFile = `/tmp/schema-${tenantId}.mjs`;
123
+ fs.writeFileSync(cacheFile, bundledCode);
124
+
125
+ const schemaModule = await importSchemaModule(cacheFile);
126
+ schemaCache.set(tenantId, schemaModule.syncSchema);
127
+
128
+ console.log(`Schema loaded and cached for tenant ${tenantId}`);
129
+ }
130
+
131
+ const schema = await loadSchemaForTenant(tenantId);
132
+
133
+ // Handle both schema formats
134
+ let schemaKeys = [];
135
+ if (schema) {
136
+ if (schema.schemas) {
137
+ schemaKeys = Object.keys(schema.schemas);
138
+ } else {
139
+ schemaKeys = Object.keys(schema);
140
+ }
141
+ }
142
+
143
+ res.json({
144
+ success: true,
145
+ hasSchema: !!schema,
146
+ schemaKeys: schemaKeys
147
+ });
148
+ } catch (error) {
149
+ console.error('Error loading schema:', error);
150
+ res.status(500).json({ error: error.message });
151
+ }
152
+ });
153
+
154
+ app.post('/validate', async (req, res) => {
155
+ try {
156
+ const { data, schemaKey, bundledCode, context } = req.body;
157
+ const { tenantId } = req.query;
158
+
159
+ if (!tenantId || !schemaKey || data === undefined || !bundledCode) {
160
+ return res.status(400).json({
161
+ valid: false,
162
+ errors: [{ path: [], message: 'Missing tenantId, schemaKey, data, or bundledCode' }]
163
+ });
164
+ }
165
+
166
+ const tempFile = `/tmp/schema-${tenantId}-${Date.now()}.mjs`;
167
+ fs.writeFileSync(tempFile, bundledCode);
168
+
169
+ const schemaModule = await importSchemaModule(tempFile);
170
+ fs.unlinkSync(tempFile);
171
+
172
+ let schemaDefinition = null;
173
+ if (schemaModule.syncSchema && schemaModule.syncSchema.schemas && schemaModule.syncSchema.schemas[schemaKey]) {
174
+ schemaDefinition = schemaModule.syncSchema.schemas[schemaKey];
175
+ }
176
+
177
+ if (!schemaDefinition) {
178
+ return res.status(404).json({
179
+ valid: false,
180
+ errors: [{ path: [], message: `Schema '${schemaKey}' not found within the provided bundle.` }]
181
+ });
182
+ }
183
+
184
+ const validator = schemaDefinition.validate;
185
+ if (typeof validator !== 'function') {
186
+ throw new Error(`Validator function not found for schema '${schemaKey}'`);
187
+ }
188
+
189
+ // Pass the context from the request
190
+ const result = validator(data, context || {});
191
+
192
+ // --- CORRECTED LOGIC ---
193
+ if (!result.success) {
194
+ // The correct property name from Zod is .issues, not .errors
195
+ return res.status(422).json({
196
+ valid: false,
197
+ // Using the correct "issues" property from the Zod error object
198
+ errors: result.error.issues
199
+ });
200
+ }
201
+ // --- END CORRECTION ---
202
+
203
+ res.json({ valid: true, errors: [] });
204
+
205
+ } catch (error) {
206
+ console.error('[Container] /validate Error:', error);
207
+ // Also correct the property name here in the catch block for safety
208
+ if (error.name === 'ZodError') {
209
+ return res.status(422).json({
210
+ valid: false,
211
+ errors: error.issues // Use .issues here as well
212
+ });
213
+ }
214
+ res.status(500).json({ valid: false, errors: [{ path: [], message: error.message }] });
215
+ }
216
+ });
217
+ app.post('/validate-path', async (req, res) => {
218
+ try {
219
+ const { path, value, schemaKey, bundledCode } = req.body;
220
+ const { tenantId } = req.query;
221
+
222
+ if (!tenantId || !schemaKey || !bundledCode || !path) {
223
+ return res.status(400).json({
224
+ valid: false,
225
+ errors: [{ path: [], message: 'Missing required parameters' }]
226
+ });
227
+ }
228
+
229
+ // Write and load schema
230
+ const tempFile = `/tmp/schema-${tenantId}-${Date.now()}.mjs`;
231
+ fs.writeFileSync(tempFile, bundledCode);
232
+ const schemaModule = await importSchemaModule(tempFile);
233
+ fs.unlinkSync(tempFile);
234
+
235
+ // Get the specific schema from the schemas collection
236
+ const schemaDefinition = schemaModule.syncSchema?.schemas?.[schemaKey];
237
+
238
+ if (!schemaDefinition) {
239
+ return res.status(404).json({
240
+ valid: false,
241
+ errors: [{ path: [], message: `Schema '${schemaKey}' not found` }]
242
+ });
243
+ }
244
+
245
+ // The validation schema is at schemaDefinition.schemas.validation
246
+ // NOT schemaDefinition.schemas?.validation (which would be looking for a nested schemas.schemas)
247
+ if (!schemaDefinition.schemas || !schemaDefinition.schemas.validation) {
248
+ return res.status(404).json({
249
+ valid: false,
250
+ errors: [{ path: [], message: `Validation schema not found for '${schemaKey}'` }]
251
+ });
252
+ }
253
+
254
+ // Walk to the target field
255
+ let fieldValidator = schemaDefinition.schemas.validation;
256
+
257
+ for (let i = 0; i < path.length; i++) {
258
+ const segment = path[i];
259
+
260
+ if (typeof segment === 'number' || segment.startsWith('id:')) {
261
+ // Array element
262
+ if (fieldValidator._def?.typeName === 'ZodArray') {
263
+ fieldValidator = fieldValidator._def.type || fieldValidator.element;
264
+ } else {
265
+ return res.json({ valid: true }); // Path doesn't exist in schema
266
+ }
267
+ } else {
268
+ // Object property
269
+ if (fieldValidator.shape) {
270
+ fieldValidator = fieldValidator.shape[segment];
271
+ } else if (fieldValidator._def?.shape) {
272
+ const shape = fieldValidator._def.shape();
273
+ fieldValidator = shape[segment];
274
+ } else {
275
+ return res.json({ valid: true }); // Path doesn't exist in schema
276
+ }
277
+ }
278
+
279
+ if (!fieldValidator) {
280
+ return res.json({ valid: true }); // No validator for this path
281
+ }
282
+ }
283
+
284
+ // Validate the value
285
+ const result = fieldValidator.safeParse(value);
286
+
287
+ if (!result.success) {
288
+ return res.status(422).json({
289
+ valid: false,
290
+ errors: result.error.issues.map(issue => ({
291
+ path: issue.path, // Just the relative path from Zod
292
+ message: issue.message,
293
+ code: issue.code
294
+ }))
295
+ });
296
+ }
297
+
298
+ res.json({ valid: true, errors: [] });
299
+
300
+ } catch (error) {
301
+ console.error('[Container] /validate-path Error:', error);
302
+ res.status(500).json({
303
+ valid: false,
304
+ errors: [{ path: [], message: error.message }]
305
+ });
306
+ }
307
+ });
308
+ app.post('/get-api-routes', async (req, res) => {
309
+ try {
310
+ // Destructure params from the body, which will be sent by the DO
311
+ const { tenantId, schemaKey, bundledCode, params } = req.body;
312
+
313
+ if (!tenantId || !schemaKey || !bundledCode) {
314
+ return res.status(400).json({ error: 'Missing tenantId, schemaKey, or bundledCode' });
315
+ }
316
+
317
+ const justSchemaKey = schemaKey.split('[')[0];
318
+ const tempFile = `/tmp/schema-${tenantId}-${Date.now()}.mjs`;
319
+ fs.writeFileSync(tempFile, bundledCode);
320
+
321
+ const schemaModule = await importSchemaModule(tempFile);
322
+ fs.unlinkSync(tempFile);
323
+
324
+ const schemaDefinition = schemaModule.syncSchema?.schemas?.[justSchemaKey];
325
+
326
+ if (!schemaDefinition) {
327
+ return res.status(404).json({ error: `Schema '${justSchemaKey}' not found in bundle.` });
328
+ }
329
+
330
+ const apiConfig = schemaDefinition.api || {};
331
+
332
+ // Process both queryData and mutateData with the same logic
333
+ const processApiConfig = (config) => {
334
+ if (!config) return null;
335
+
336
+ // Check if it's an apiWithParams object
337
+ if (config && typeof config.handler === 'function') {
338
+ if (!params || typeof params !== 'object') {
339
+ throw new Error('Missing or invalid params object for parameterized route.');
340
+ }
341
+
342
+ // Validate the params object directly with Zod
343
+ const validationResult = schemaDefinition.validateApiParams(config, params);
344
+
345
+ if (!validationResult.success) {
346
+ throw new Error(`API parameter validation failed: ${JSON.stringify(validationResult.error.flatten())}`);
347
+ }
348
+
349
+ // Execute handler with the validated params object
350
+ const result = config.handler(validationResult.data);
351
+
352
+ // Determine if it's GET or POST based on return type
353
+ if (typeof result === 'string') {
354
+ // Just a URL = GET request
355
+ return { url: result, method: 'GET' };
356
+ } else if (result && typeof result === 'object' && result.url) {
357
+ // Object with url and optional data = POST if data exists, otherwise GET
358
+ return {
359
+ url: result.url,
360
+ method: result.data ? 'POST' : 'GET',
361
+ data: result.data
362
+ };
363
+ }
364
+
365
+ throw new Error('Invalid handler return value');
366
+ } else if (typeof config === 'string') {
367
+ // Static string = GET request
368
+ return { url: config, method: 'GET' };
369
+ }
370
+
371
+ return null;
372
+ };
373
+
374
+ try {
375
+ const queryConfig = processApiConfig(apiConfig.queryData);
376
+ const mutateConfig = processApiConfig(apiConfig.mutateData);
377
+
378
+ res.json({
379
+ queryData: queryConfig,
380
+ mutateData: mutateConfig
381
+ });
382
+ } catch (error) {
383
+ return res.status(400).json({ error: error.message });
384
+ }
385
+
386
+ } catch (error) {
387
+ console.error('[Container] /get-api-routes Error:', error);
388
+ res.status(500).json({ error: error.message });
389
+ }
390
+ });
391
+
392
+
393
+ app.post('/process-notifications-batch', async (req, res) => {
394
+ try {
395
+ // 1. Accept the new batch payload structure
396
+ const { bundledCode, fullState, contexts, operation } = req.body;
397
+ const { tenantId } = req.query;
398
+
399
+ // Validate the incoming batch payload
400
+ if (!tenantId || !bundledCode || !fullState || !Array.isArray(contexts)) {
401
+ return res.status(400).json({ error: 'Missing tenantId, bundledCode, fullState, or contexts array' });
402
+ }
403
+
404
+ // 2. Use the stateless pattern (UNCHANGED): write, import, and delete the temp file.
405
+ const tempFile = `/tmp/schema-${tenantId}-${Date.now()}.mjs`;
406
+ fs.writeFileSync(tempFile, bundledCode);
407
+ const schemaModule = await importSchemaModule(tempFile);
408
+ fs.unlinkSync(tempFile);
409
+
410
+ // 3. Get the schema definition from the imported module (UNCHANGED).
411
+ const schema = schemaModule.syncSchema;
412
+ if (!schema) {
413
+ return res.status(404).json({ error: 'syncSchema not found in the provided bundle.' });
414
+ }
415
+
416
+ // 4. The notification functions are attached to the top-level schema object (UNCHANGED).
417
+ const notificationFunctions = schema.notifications;
418
+ if (!notificationFunctions) {
419
+ // No notifications defined, return an empty object.
420
+ return res.json({});
421
+ }
422
+
423
+ // This will become the final response object: { userId: [notifications] }
424
+ const notificationsByUserId = {};
425
+
426
+ // 5. BATCH PROCESSING LOOP: Iterate over the array of contexts.
427
+ for (const context of contexts) {
428
+ // Ensure context is valid before processing
429
+ if (!context || !context.userId) continue;
430
+
431
+ const matchedNotificationsForUser = [];
432
+
433
+ // 6. Execute all notification functions for this specific context.
434
+ for (const channelName in notificationFunctions) {
435
+ try {
436
+ const notificationFn = notificationFunctions[channelName];
437
+
438
+ // Execute the function with the full state and the current user's context
439
+ const result = notificationFn( context,fullState);
440
+
441
+ if (result !== null && result !== undefined) {
442
+ matchedNotificationsForUser.push({
443
+ channel: channelName,
444
+ payload: { ...result },
445
+ });
446
+ }
447
+ } catch (fnError) {
448
+ console.error(`[Container] Error executing notification function '${channelName}' for user '${context.userId}':`, fnError);
449
+ // Continue to the next notification function, do not stop the batch
450
+ }
451
+ }
452
+
453
+ // 7. If any notifications were generated for this user, add them to the results map.
454
+ if (matchedNotificationsForUser.length > 0) {
455
+ notificationsByUserId[context.userId] = matchedNotificationsForUser;
456
+ }
457
+ }
458
+
459
+ // 8. Send the single, consolidated response back to the Durable Object.
460
+ res.json(notificationsByUserId);
461
+
462
+ } catch (error) {
463
+ console.error('[Container] /process-notifications-batch Error:', error);
464
+ res.status(500).json({ error: 'Internal server error processing notification batch', details: error.message });
465
+ }
466
+ });
467
+
468
+ // Error handling middleware
469
+ app.use((error, req, res, next) => {
470
+ console.error('Unhandled error:', error);
471
+ res.status(500).json({ error: 'Internal server error' });
472
+ });
473
+
474
+ // Graceful shutdown
475
+ process.on('SIGTERM', () => {
476
+ console.log('Received SIGTERM, shutting down gracefully');
477
+ process.exit(0);
478
+ });
479
+
480
+ process.on('SIGINT', () => {
481
+ console.log('Received SIGINT, shutting down gracefully');
482
+ process.exit(0);
483
+ });
484
+
485
+ const PORT = process.env.PORT || 8080;
486
+ app.listen(PORT, '0.0.0.0', () => {
487
+ console.log(`Schema processor listening on port ${PORT}`);
488
+ });
@@ -0,0 +1,40 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+
3
+ export interface UserPresence {
4
+ forwardNotification(message: object): Promise<void>;
5
+ }
6
+
7
+ export class UserPresenceDO extends DurableObject implements UserPresence {
8
+ async fetch(request: Request) {
9
+ if (request.headers.get('Upgrade') !== 'websocket') {
10
+ return new Response('Expected WebSocket', { status: 400 });
11
+ }
12
+
13
+ const pair = new WebSocketPair();
14
+ const [client, server] = Object.values(pair);
15
+
16
+ this.ctx.acceptWebSocket(server);
17
+
18
+ return new Response(null, { status: 101, webSocket: client });
19
+ }
20
+
21
+ // This method will be called by other Durable Objects (the WebSocketSyncEngine)
22
+ async forwardNotification(message: object): Promise<void> {
23
+ console.log(`[UserPresenceDO] Forwarding notification:`, message);
24
+ const messageString = JSON.stringify(message);
25
+
26
+ // Broadcast to all sessions for this user (e.g., laptop and phone)
27
+ for (const ws of this.ctx.getWebSockets()) {
28
+ try {
29
+ ws.send(messageString);
30
+ } catch (e) {
31
+ // The socket might be closing. This is normal.
32
+ console.warn('[UserPresenceDO] Failed to send to a closing socket.');
33
+ }
34
+ }
35
+ }
36
+
37
+ async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
38
+ console.log(`[UserPresenceDO] Socket closed. Code: ${code}, Reason: ${reason}`);
39
+ }
40
+ }
@@ -0,0 +1,58 @@
1
+ import { sign, verify } from '@tsndr/cloudflare-worker-jwt';
2
+
3
+ export class AuthError extends Error {
4
+ constructor(
5
+ message: string,
6
+ public status: number,
7
+ ) {
8
+ super(message);
9
+ this.name = 'AuthError';
10
+ }
11
+ }
12
+
13
+ type AuthResult = {
14
+ success: boolean;
15
+ valid: boolean;
16
+ tenantId: number;
17
+ serviceId: number;
18
+ scopes: string[];
19
+ };
20
+
21
+ export class AuthService {
22
+ constructor(
23
+ private jwtSecret: string,
24
+ private authSecret: string,
25
+ ) {}
26
+
27
+ async validateApiKey(apiKey: string): Promise<AuthResult> {
28
+ if (apiKey !== this.authSecret) {
29
+ return { success: false, valid: false, tenantId: 0, serviceId: 0, scopes: [] };
30
+ }
31
+
32
+ return {
33
+ success: true,
34
+ valid: true,
35
+ tenantId: 1,
36
+ serviceId: 1,
37
+ scopes: ['*'],
38
+ };
39
+ }
40
+
41
+ extractBearerToken(request: Request): string {
42
+ const authHeader = request.headers.get('Authorization');
43
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
44
+ throw new AuthError('Missing or invalid authorization header', 401);
45
+ }
46
+ return authHeader.substring(7);
47
+ }
48
+
49
+ async verifyToken(token: string): Promise<any> {
50
+ const isValid = await verify(token, this.jwtSecret);
51
+ if (!isValid) throw new AuthError('Invalid token', 401);
52
+ return JSON.parse(atob(token.split('.')[1]));
53
+ }
54
+
55
+ async signToken(payload: Record<string, any>): Promise<string> {
56
+ return sign(payload, this.jwtSecret);
57
+ }
58
+ }