arcway 0.1.7 → 0.1.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,12 +9,12 @@ function listenerPathToEvent(relativePath) {
9
9
  .replace(/\[\.\.\.([^\]]+)\]/g, '*');
10
10
  }
11
11
 
12
- function validateListener(exported, filePath) {
13
- if (!exported) throw new Error(`Listener file at ${filePath} must have a default export`);
14
- if (typeof exported === 'function') return { handler: exported };
15
- if (typeof exported === 'object' && typeof exported.handler === 'function') return exported;
12
+ function validateHandler(item, filePath, index) {
13
+ const label = index !== undefined ? `[${index}]` : '';
14
+ if (typeof item === 'function') return item;
15
+ if (typeof item === 'object' && item !== null && typeof item.handler === 'function') return item.handler;
16
16
  throw new Error(
17
- `Listener at ${filePath} must export a function or an object with a "handler" function`,
17
+ `Listener at ${filePath}${label} must be a function or an object with a "handler" function`,
18
18
  );
19
19
  }
20
20
 
@@ -47,15 +47,22 @@ class EventHandler {
47
47
  label: 'listener file',
48
48
  });
49
49
  for (const { filePath, relativePath, module } of entries) {
50
- const { handler, event: explicitEvent } = validateListener(module.default, filePath);
51
- const event = explicitEvent ?? listenerPathToEvent(relativePath);
50
+ const exported = module.default;
51
+ if (!exported) throw new Error(`Listener file at ${filePath} must have a default export`);
52
+
53
+ const event = listenerPathToEvent(relativePath);
52
54
  if (event.startsWith('system/') || event === 'system') continue;
53
- this._events.subscribe(event, (_eventName, payload) => {
54
- const ctx = buildContext(this._appContext, { event: { name: _eventName, payload } });
55
- handler(ctx);
56
- });
57
- this._listeners.push({ event, fileName: relativePath });
58
- this._log?.info(` ${relativePath} ${event}`);
55
+
56
+ const items = Array.isArray(exported) ? exported : [exported];
57
+ for (let i = 0; i < items.length; i++) {
58
+ const handler = validateHandler(items[i], filePath, items.length > 1 ? i : undefined);
59
+ this._events.subscribe(event, (_eventName, payload) => {
60
+ const ctx = buildContext(this._appContext, { event: { name: _eventName, payload } });
61
+ handler(ctx);
62
+ });
63
+ this._listeners.push({ event, fileName: relativePath });
64
+ }
65
+ this._log?.info(` ${relativePath} → ${event}${items.length > 1 ? ` (${items.length} listeners)` : ''}`);
59
66
  }
60
67
  }
61
68
 
@@ -2,11 +2,12 @@ import { buildContext } from '../../context.js';
2
2
  import { validateEnqueue, toError, calculateBackoff } from '../queue.js';
3
3
  import { checkDbThroughput } from '../throughput.js';
4
4
  import LeaseManager from './lease.js';
5
- const STALE_JOB_TIMEOUT_MS = 5 * 60 * 1e3;
5
+ const DEFAULT_STALE_TIMEOUT_MS = 5 * 60 * 1e3;
6
6
  class KnexJobQueue {
7
7
  db;
8
8
  tableName;
9
9
  backoffMs;
10
+ staleTimeoutMs;
10
11
  registered = new Map();
11
12
  _size = 0;
12
13
  leaseManager;
@@ -14,6 +15,7 @@ class KnexJobQueue {
14
15
  this.db = db;
15
16
  this.tableName = options?.tableName ?? 'arcway_jobs';
16
17
  this.backoffMs = options?.backoffMs ?? 1000;
18
+ this.staleTimeoutMs = options?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS;
17
19
  this.leaseManager = new LeaseManager(db, { tableName: options?.leaseTableName });
18
20
  }
19
21
  /** Create the jobs table if it doesn't exist. Must be called before use. */
@@ -74,10 +76,24 @@ class KnexJobQueue {
74
76
  }
75
77
 
76
78
  async _recoverStaleJobs(now) {
77
- const staleCutoff = new Date(now - STALE_JOB_TIMEOUT_MS).toISOString();
78
- await this.db(this.tableName)
79
+ const runningJobs = await this.db(this.tableName)
79
80
  .where('status', 'running')
80
- .andWhere('updated_at', '<', staleCutoff)
81
+ .select('id', 'qualified_name', 'updated_at');
82
+ if (runningJobs.length === 0) return;
83
+
84
+ const staleIds = [];
85
+ for (const row of runningJobs) {
86
+ const reg = this.registered.get(row.qualified_name);
87
+ const timeout = reg?.definition.staleTimeout ?? this.staleTimeoutMs;
88
+ const updatedAt = new Date(row.updated_at).getTime();
89
+ if (now - updatedAt > timeout) {
90
+ staleIds.push(row.id);
91
+ }
92
+ }
93
+ if (staleIds.length === 0) return;
94
+
95
+ await this.db(this.tableName)
96
+ .whereIn('id', staleIds)
81
97
  .update({ status: 'pending', updated_at: new Date().toISOString() });
82
98
  }
83
99
 
@@ -9,18 +9,19 @@ import { SYSTEM_JOB_DOMAIN, registerSystemJobs } from '../system-jobs/index.js';
9
9
  async function discoverJobs(jobsDir) {
10
10
  const entries = await discoverModules(jobsDir, { recursive: true, label: 'job file' });
11
11
  const jobs = [];
12
- for (const { name, filePath, module } of entries) {
12
+ for (const { name, filePath, relativePath, module } of entries) {
13
13
  const job = module.default;
14
14
  if (!job || typeof job !== 'object') {
15
15
  throw new Error(`Job file at ${filePath} must have a default export`);
16
16
  }
17
- if (!job.name || typeof job.name !== 'string') {
18
- throw new Error(`Job at ${filePath} must export a "name" string`);
19
- }
20
17
  if (typeof job.handler !== 'function') {
21
18
  throw new Error(`Job at ${filePath} must export a "handler" function`);
22
19
  }
23
- jobs.push({ definition: job, fileName: name });
20
+ // Default job name from file path: billing/send-invoice.js → billing/send-invoice
21
+ if (!job.name) {
22
+ job.name = relativePath.replace(/\\/g, '/').replace(/\.js$/, '');
23
+ }
24
+ jobs.push({ definition: job, fileName: name, cooldownMs: job.cooldownMs, staleTimeout: job.staleTimeout });
24
25
  }
25
26
  return jobs;
26
27
  }
@@ -41,7 +42,7 @@ class JobRunner {
41
42
  constructor(config, { db, queue, cache, files, mail, events, log } = {}) {
42
43
  this._config = config;
43
44
  this._log = log;
44
- this._dispatcher = new JobDispatcher({ backoffMs: config?.backoffMs });
45
+ this._dispatcher = new JobDispatcher({ backoffMs: config?.backoffMs, staleTimeoutMs: config?.staleTimeoutMs });
45
46
  this._appContext = { db, queue, cache, files, mail, events, log };
46
47
  }
47
48
 
@@ -70,6 +71,8 @@ class JobRunner {
70
71
  fileName,
71
72
  schedule: definition.schedule,
72
73
  handler: definition.handler,
74
+ cooldownMs: definition.cooldownMs,
75
+ staleTimeout: definition.staleTimeout,
73
76
  });
74
77
  this._log?.info(` ${fileName} \u2192 ${definition.name}`);
75
78
  }
@@ -95,6 +98,7 @@ class JobRunner {
95
98
  this._continuousJobs.push({
96
99
  qualifiedName: `app/${job.jobName}`,
97
100
  handler: job.handler,
101
+ cooldownMs: job.cooldownMs,
98
102
  appContext: this._appContext,
99
103
  });
100
104
  } else if (job.schedule) {
@@ -169,13 +173,23 @@ class JobRunner {
169
173
 
170
174
  async _runContinuousLoop(entry) {
171
175
  const backoffMs = this._config?.backoffMs ?? 1000;
176
+ const cooldownMs = entry.cooldownMs ?? this._config?.cooldownMs ?? 1000;
172
177
  let consecutiveErrors = 0;
173
178
  console.log(`[job-runner] continuous: ${entry.qualifiedName} started`);
174
179
  while (!this._stopped) {
175
180
  try {
181
+ const start = Date.now();
176
182
  const ctx = buildContext(entry.appContext, { payload: void 0 });
177
183
  await entry.handler(ctx);
178
184
  consecutiveErrors = 0;
185
+ const elapsed = Date.now() - start;
186
+ if (cooldownMs > 0 && elapsed < cooldownMs) {
187
+ const remaining = cooldownMs - elapsed;
188
+ const deadline = Date.now() + remaining;
189
+ while (!this._stopped && Date.now() < deadline) {
190
+ await new Promise((r) => setTimeout(r, Math.min(100, deadline - Date.now())));
191
+ }
192
+ }
179
193
  } catch (err) {
180
194
  consecutiveErrors++;
181
195
  const error = toError(err);
@@ -116,7 +116,7 @@ class ApiRouter {
116
116
  }
117
117
 
118
118
  async executeRoute(route, reqInfo) {
119
- const middlewareFns = getMiddlewareForRoute(this._middleware, route.pattern);
119
+ const middlewareFns = getMiddlewareForRoute(this._middleware, route.pattern, route.method);
120
120
  const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
121
121
  const ctx = this._buildCtx(reqInfo);
122
122
  try {
@@ -225,7 +225,7 @@ class ApiRouter {
225
225
  };
226
226
 
227
227
  // ── Middleware + handler ──
228
- const middlewareFns = getMiddlewareForRoute(this._middleware, route.pattern);
228
+ const middlewareFns = getMiddlewareForRoute(this._middleware, route.pattern, method);
229
229
  const middlewareNames = middlewareFns.map((mw) => mw.name || 'anonymous');
230
230
  const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
231
231
  const ctx = this._buildCtx(reqInfo);
@@ -1,6 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import { discoverModules } from '../discovery.js';
3
3
  import { validateRequestSchema } from '../validation.js';
4
+
5
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
6
+
4
7
  function isMiddlewareConfig(value) {
5
8
  return (
6
9
  typeof value === 'object' &&
@@ -22,6 +25,23 @@ function wrapMiddlewareConfig(config) {
22
25
  return handler(ctx);
23
26
  };
24
27
  }
28
+
29
+ function parseMiddlewareItems(exported, filePath, label) {
30
+ const items = Array.isArray(exported) ? exported : [exported];
31
+ const fns = [];
32
+ for (let i = 0; i < items.length; i++) {
33
+ const item = items[i];
34
+ if (isMiddlewareConfig(item)) {
35
+ fns.push(wrapMiddlewareConfig(item));
36
+ } else {
37
+ throw new Error(
38
+ `Middleware file "${filePath}" ${label}${items.length > 1 ? `[${i}]` : ''} must be a MiddlewareConfig object with a handler function. Got ${typeof item}.`,
39
+ );
40
+ }
41
+ }
42
+ return fns;
43
+ }
44
+
25
45
  async function discoverMiddleware(apiDir) {
26
46
  const middleware = [];
27
47
  const entries = await discoverModules(apiDir, {
@@ -38,38 +58,51 @@ async function discoverMiddleware(apiDir) {
38
58
  const converted = dirParts.map((p) => p.replace(/\[([^\]]+)\]/g, ':$1'));
39
59
  pathPrefix = '/' + converted.join('/');
40
60
  }
41
- {
42
- const exported = module.default;
43
- if (!exported) {
44
- throw new Error(
45
- `Middleware file "${filePath}" must have a default export (MiddlewareConfig or array of MiddlewareConfig)`,
46
- );
47
- }
48
- const items = Array.isArray(exported) ? exported : [exported];
49
- const fns = [];
50
- for (let i = 0; i < items.length; i++) {
51
- const item = items[i];
52
- if (isMiddlewareConfig(item)) {
53
- fns.push(wrapMiddlewareConfig(item));
54
- } else {
55
- throw new Error(
56
- `Middleware file "${filePath}" default export${items.length > 1 ? `[${i}]` : ''} must be a MiddlewareConfig object with a handler function. Got ${typeof item}.`,
57
- );
61
+
62
+ const hasDefault = module.default != null;
63
+ const hasMethodExports = HTTP_METHODS.some((m) => module[m] != null);
64
+
65
+ if (!hasDefault && !hasMethodExports) {
66
+ throw new Error(
67
+ `Middleware file "${filePath}" must have a default export or method-specific exports (GET, POST, etc.)`,
68
+ );
69
+ }
70
+
71
+ const entry = { pathPrefix, fns: [] };
72
+
73
+ if (hasDefault) {
74
+ entry.fns = parseMiddlewareItems(module.default, filePath, 'default export');
75
+ }
76
+
77
+ if (hasMethodExports) {
78
+ entry.methodFns = {};
79
+ for (const method of HTTP_METHODS) {
80
+ if (module[method] != null) {
81
+ entry.methodFns[method] = parseMiddlewareItems(module[method], filePath, `${method} export`);
58
82
  }
59
83
  }
60
- middleware.push({ pathPrefix, fns });
61
84
  }
85
+
86
+ middleware.push(entry);
62
87
  }
63
88
  middleware.sort((a, b) => a.pathPrefix.length - b.pathPrefix.length);
64
89
  return middleware;
65
90
  }
66
- function getMiddlewareForRoute(allMiddleware, routePattern) {
91
+ function getMiddlewareForRoute(allMiddleware, routePattern, method) {
67
92
  const matching = [];
93
+ const upperMethod = method?.toUpperCase();
68
94
  for (const mw of allMiddleware) {
69
- if (mw.pathPrefix === '/') {
70
- matching.push(...mw.fns);
71
- } else if (routePattern === mw.pathPrefix || routePattern.startsWith(mw.pathPrefix + '/')) {
72
- matching.push(...mw.fns);
95
+ const pathMatches = mw.pathPrefix === '/' ||
96
+ routePattern === mw.pathPrefix ||
97
+ routePattern.startsWith(mw.pathPrefix + '/');
98
+ if (!pathMatches) continue;
99
+
100
+ // Add default (all-method) middleware
101
+ matching.push(...mw.fns);
102
+
103
+ // Add method-specific middleware
104
+ if (upperMethod && mw.methodFns?.[upperMethod]) {
105
+ matching.push(...mw.methodFns[upperMethod]);
73
106
  }
74
107
  }
75
108
  return matching;
@@ -82,7 +82,7 @@ function createRealtimeServer(options) {
82
82
  };
83
83
  }
84
84
  async function runHandler(route, reqInfo) {
85
- const middlewareFns = getMiddlewareForRoute(middleware, route.pattern);
85
+ const middlewareFns = getMiddlewareForRoute(middleware, route.pattern, 'GET');
86
86
  const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
87
87
  const ctx = buildCtx(reqInfo);
88
88
  try {