arcway 0.4.20 → 0.5.0

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.4.20",
3
+ "version": "0.5.0",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/server/bin/cli.js CHANGED
@@ -13,6 +13,7 @@ import registerSchema from './commands/schema.js';
13
13
  import registerMigrate from './commands/migrate.js';
14
14
  import registerBootstrap from './commands/bootstrap.js';
15
15
  import registerVault from './commands/vault.js';
16
+ import registerEnv from './commands/env.js';
16
17
 
17
18
  function getPackageVersion() {
18
19
  try {
@@ -40,6 +41,7 @@ function createProgram() {
40
41
  registerMigrate(program);
41
42
  registerBootstrap(program);
42
43
  registerVault(program);
44
+ registerEnv(program);
43
45
  return program;
44
46
  }
45
47
 
@@ -0,0 +1,30 @@
1
+ import { makeConfig } from '#server/config/loader.js';
2
+ import { loadEnvFiles } from '#server/env.js';
3
+
4
+ async function runEnvCheck(options = {}) {
5
+ const rootDir = process.cwd();
6
+ const mode =
7
+ options.mode ?? (process.env.NODE_ENV === 'production' ? 'production' : 'development');
8
+ loadEnvFiles(rootDir, mode);
9
+ await makeConfig(rootDir, { mode });
10
+ console.log(`Environment is valid for ${mode}.`);
11
+ }
12
+
13
+ function register(program) {
14
+ const env = program.command('env').description('Environment declaration utilities');
15
+ env
16
+ .command('check')
17
+ .description('Validate declared environment variables without starting the application')
18
+ .option('--mode <mode>', 'configuration mode')
19
+ .action(async (options) => {
20
+ try {
21
+ await runEnvCheck(options);
22
+ } catch (error) {
23
+ console.error(error instanceof Error ? error.message : String(error));
24
+ process.exitCode = 1;
25
+ }
26
+ });
27
+ }
28
+
29
+ export { runEnvCheck };
30
+ export default register;
@@ -1,4 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { createProgram } from './cli.js';
3
3
  const program = createProgram();
4
- program.parse(process.argv);
4
+ try {
5
+ await program.parseAsync(process.argv);
6
+ } catch (error) {
7
+ console.error(error instanceof Error ? error.message : String(error));
8
+ process.exitCode = 1;
9
+ }
@@ -56,6 +56,8 @@ async function boot(options) {
56
56
  // the init hook (e.g. wrapping `appContext.db`) persist for process lifetime
57
57
  // because downstream consumers read properties off this reference at use time.
58
58
  const appContext = {
59
+ config,
60
+ env: config.env,
59
61
  db,
60
62
  redis,
61
63
  events,
@@ -112,6 +114,7 @@ async function boot(options) {
112
114
  workerPool,
113
115
  plugins,
114
116
  appConfig: config,
117
+ env: config.env,
115
118
  });
116
119
  await jobRunner.init();
117
120
 
@@ -140,6 +143,7 @@ async function boot(options) {
140
143
  const healthDeps = {
141
144
  db,
142
145
  redisClients: [{ name: 'redis', client: redis?.client }],
146
+ mail,
143
147
  };
144
148
  const callbackHandlers = await discoverRootCallbacks(rootDir);
145
149
  const pluginCallbacks = (await plugins.discoverCallbacks?.()) ?? new Map();
@@ -20,6 +20,7 @@ import resolveMcp from './modules/mcp.js';
20
20
  import resolveWebsocket from './modules/websocket.js';
21
21
  import resolveSeeds from './modules/seeds.js';
22
22
  import resolvePlugins from './modules/plugins.js';
23
+ import resolveEnv from './modules/env.js';
23
24
 
24
25
  function deepMerge(target, source) {
25
26
  const result = { ...target };
@@ -40,6 +41,7 @@ function deepMerge(target, source) {
40
41
  }
41
42
 
42
43
  const modules = [
44
+ resolveEnv,
43
45
  resolveServer,
44
46
  resolveApi,
45
47
  resolveDatabase,
@@ -0,0 +1,68 @@
1
+ function isPlainObject(value) {
2
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
3
+ }
4
+
5
+ function describe(name, declaration, issue) {
6
+ return declaration?.description
7
+ ? `${name}: ${declaration.description} — ${issue}`
8
+ : `${name}: ${issue}`;
9
+ }
10
+
11
+ function resolve(config, { mode } = {}) {
12
+ if (config.env === undefined) return { ...config, env: Object.freeze({}) };
13
+ if (!isPlainObject(config.env)) {
14
+ throw new Error('Invalid environment configuration:\nenv: must be an object');
15
+ }
16
+
17
+ const errors = [];
18
+ const resolved = {};
19
+ for (const [name, declaration] of Object.entries(config.env)) {
20
+ if (!isPlainObject(declaration)) {
21
+ errors.push(`${name}: declaration must be an object`);
22
+ continue;
23
+ }
24
+
25
+ const { required, default: defaultValue, values, description } = declaration;
26
+ const requiredValid =
27
+ required === undefined ||
28
+ typeof required === 'boolean' ||
29
+ (Array.isArray(required) && required.every((value) => typeof value === 'string'));
30
+ if (!requiredValid)
31
+ errors.push(
32
+ describe(name, declaration, 'required must be true, false, or an array of modes'),
33
+ );
34
+ if (description !== undefined && typeof description !== 'string') {
35
+ errors.push(`${name}: description must be a string`);
36
+ }
37
+ if (
38
+ values !== undefined &&
39
+ (!Array.isArray(values) || !values.every((value) => typeof value === 'string'))
40
+ ) {
41
+ errors.push(describe(name, declaration, 'values must be an array of strings'));
42
+ }
43
+
44
+ const isRequired = required === true || (Array.isArray(required) && required.includes(mode));
45
+ if (required && defaultValue !== undefined) {
46
+ errors.push(describe(name, declaration, 'required variables cannot declare a default'));
47
+ }
48
+
49
+ let value = process.env[name];
50
+ if (value === undefined && !required && defaultValue !== undefined) {
51
+ value = String(defaultValue);
52
+ process.env[name] = value;
53
+ }
54
+ if (value === undefined && isRequired) {
55
+ errors.push(describe(name, declaration, 'is required'));
56
+ } else if (value !== undefined && Array.isArray(values) && !values.includes(value)) {
57
+ errors.push(describe(name, declaration, `has invalid value ${JSON.stringify(value)}`));
58
+ }
59
+ resolved[name] = value;
60
+ }
61
+
62
+ if (errors.length > 0) {
63
+ throw new Error(`Invalid environment configuration:\n${errors.join('\n')}`);
64
+ }
65
+ return { ...config, env: Object.freeze(resolved) };
66
+ }
67
+
68
+ export default resolve;
@@ -4,6 +4,8 @@ import { normalizePort } from '../port.js';
4
4
  const DEFAULTS = {
5
5
  driver: 'console',
6
6
  enabled: true,
7
+ maxAttempts: 5,
8
+ backoffMs: [60e3, 300e3, 1800e3, 7200e3, 21600e3],
7
9
  };
8
10
 
9
11
  function resolve(config) {
package/server/context.js CHANGED
@@ -140,6 +140,8 @@ function buildContext(appContext, extras) {
140
140
  }
141
141
  : null;
142
142
  const ctx = {
143
+ config: appContext.config,
144
+ env: appContext.env,
143
145
  db: tracked.db,
144
146
  log: appContext.log,
145
147
  events: appContext.events,
package/server/health.js CHANGED
@@ -28,6 +28,15 @@ async function checkHealth(deps) {
28
28
  }
29
29
  }
30
30
  }
31
+ if (deps.mail?.enabled) {
32
+ try {
33
+ const stats = await deps.mail.stats();
34
+ const failed = stats.failed ?? 0;
35
+ components.mail = { status: failed > 0 ? 'error' : 'ok', failed };
36
+ } catch (err) {
37
+ components.mail = { status: 'error', failed: null, error: toErrorMessage(err) };
38
+ }
39
+ }
31
40
  const allOk = Object.values(components).every((c) => c.status === 'ok');
32
41
  return { status: allOk ? 'ok' : 'degraded', components };
33
42
  }
@@ -66,6 +66,7 @@ class JobRunner {
66
66
  workerPool,
67
67
  plugins,
68
68
  appConfig,
69
+ env,
69
70
  } = {},
70
71
  ) {
71
72
  this._config = config;
@@ -88,6 +89,8 @@ class JobRunner {
88
89
  vault,
89
90
  callbacks,
90
91
  plugins,
92
+ config: this._appConfig,
93
+ env,
91
94
  };
92
95
  }
93
96
 
@@ -79,7 +79,14 @@ async function runTask(task) {
79
79
  secret: resolvedConfig?.vault?.values?.callback,
80
80
  });
81
81
  const ctx = buildContext(
82
- { ...services, meta: workerData?.meta, vault: resolvedConfig?.vault, callbacks },
82
+ {
83
+ ...services,
84
+ config: resolvedConfig,
85
+ env: resolvedConfig?.env,
86
+ meta: workerData?.meta,
87
+ vault: resolvedConfig?.vault,
88
+ callbacks,
89
+ },
83
90
  { payload },
84
91
  );
85
92
  return fn(ctx);
@@ -3,6 +3,7 @@ import { addSystemJobEntry } from '../system-jobs/index.js';
3
3
  import ConsoleMailDriver from './drivers/console.js';
4
4
  import SmtpMailDriver from './drivers/smtp.js';
5
5
  import { initInboundMail } from './inbound.js';
6
+ import { toErrorMessage } from '../helpers.js';
6
7
 
7
8
  const MAIL_JOB_NAME = 'send-mail';
8
9
  const MAIL_JOB_QUALIFIED = `${SYSTEM_JOB_DOMAIN}/${MAIL_JOB_NAME}`;
@@ -25,7 +26,7 @@ class Mail {
25
26
 
26
27
  if (this._enabled) {
27
28
  this._driver = createDriver(config);
28
- registerSendJob(this._driver, queue, config.throughput);
29
+ registerSendJob(this._driver, queue, config, log);
29
30
  log?.info(`Mail driver: ${config.driver}`);
30
31
  } else if (config) {
31
32
  log?.info('Mail: disabled');
@@ -69,6 +70,23 @@ class Mail {
69
70
  await initInboundMail(this._db, this._config.inbound, this._log, onEmail);
70
71
  this._log?.info(`Mail inbound: ${this._config.inbound.driver}`);
71
72
  }
73
+
74
+ async pending(options) {
75
+ return this._queue?.list(MAIL_TOPIC, { ...options, status: 'available' }) ?? [];
76
+ }
77
+
78
+ async failed(options) {
79
+ return this._queue?.list(MAIL_TOPIC, { ...options, status: 'failed' }) ?? [];
80
+ }
81
+
82
+ async retry(ids) {
83
+ if (!this._queue) throw new Error('Cannot retry mail: queue is not available.');
84
+ await this._queue.retry(ids);
85
+ }
86
+
87
+ async stats() {
88
+ return this._queue?.count(MAIL_TOPIC) ?? {};
89
+ }
72
90
  }
73
91
 
74
92
  function createDriver(config) {
@@ -86,7 +104,16 @@ function createDriver(config) {
86
104
  }
87
105
  }
88
106
 
89
- function registerSendJob(driver, queue, throughput) {
107
+ function recipientDomain(to) {
108
+ const recipient = Array.isArray(to) ? to[0] : to;
109
+ return typeof recipient === 'string' && recipient.includes('@')
110
+ ? recipient.slice(recipient.lastIndexOf('@') + 1).toLowerCase()
111
+ : 'unknown';
112
+ }
113
+
114
+ function registerSendJob(driver, queue, config, log) {
115
+ const maxAttempts = config.maxAttempts ?? 5;
116
+ const backoffMs = config.backoffMs ?? [60e3, 300e3, 1800e3, 7200e3, 21600e3];
90
117
  addSystemJobEntry({
91
118
  definition: {
92
119
  name: MAIL_JOB_NAME,
@@ -96,11 +123,28 @@ function registerSendJob(driver, queue, throughput) {
96
123
  if (!queue) return;
97
124
  const items = await queue.pop(MAIL_TOPIC, 10);
98
125
  for (const item of items) {
99
- await driver.send(item.data);
100
- await queue.remove([item.id]);
126
+ try {
127
+ await driver.send(item.data);
128
+ await queue.remove([item.id]);
129
+ } catch (error) {
130
+ const message = toErrorMessage(error);
131
+ const details = {
132
+ messageId: item.id,
133
+ recipientDomain: recipientDomain(item.data.to),
134
+ attempt: item.attempts,
135
+ error: message,
136
+ };
137
+ log?.error('Queued mail delivery failed', details);
138
+ if (item.attempts >= maxAttempts) {
139
+ await queue.fail([item.id], { error: message });
140
+ } else {
141
+ const retryInMs = backoffMs[Math.min(item.attempts - 1, backoffMs.length - 1)];
142
+ await queue.release([item.id], { error: message, retryInMs });
143
+ }
144
+ }
101
145
  }
102
146
  },
103
- throughput,
147
+ throughput: config.throughput,
104
148
  },
105
149
  shouldRegister: (config) => !!config.mail,
106
150
  description: 'Send queued outbound email',
@@ -21,10 +21,30 @@ class KnexQueueDriver {
21
21
  table.string('topic').notNullable().index();
22
22
  table.text('payload').notNullable();
23
23
  table.string('status').notNullable().defaultTo('available');
24
+ table.integer('attempts').notNullable().defaultTo(0);
25
+ table.timestamp('available_at').notNullable().defaultTo(this.db.fn.now());
26
+ table.text('last_error').nullable();
24
27
  table.timestamp('locked_at').nullable();
25
28
  table.timestamp('created_at').defaultTo(this.db.fn.now());
26
29
  });
30
+ return;
27
31
  }
32
+ const additions = [
33
+ ['attempts', (table) => table.integer('attempts').notNullable().defaultTo(0)],
34
+ // SQLite cannot add a column with a non-constant CURRENT_TIMESTAMP
35
+ // default. Backfill it below after adding a nullable column; newly
36
+ // created tables still get the non-null default above.
37
+ ['available_at', (table) => table.timestamp('available_at').nullable()],
38
+ ['last_error', (table) => table.text('last_error').nullable()],
39
+ ];
40
+ for (const [column, add] of additions) {
41
+ if (!(await this.db.schema.hasColumn(this.tableName, column))) {
42
+ await this.db.schema.alterTable(this.tableName, add);
43
+ }
44
+ }
45
+ await this.db(this.tableName)
46
+ .whereNull('available_at')
47
+ .update({ available_at: this.db.fn.now() });
28
48
  }
29
49
  async push(namespace, topic, payload) {
30
50
  await this.db(this.tableName).insert({
@@ -32,6 +52,8 @@ class KnexQueueDriver {
32
52
  topic,
33
53
  payload,
34
54
  status: 'available',
55
+ attempts: 0,
56
+ available_at: toDatabaseTimestamp(this.db, Date.now()),
35
57
  });
36
58
  }
37
59
  async pushBulk(namespace, topic, payloads) {
@@ -40,6 +62,8 @@ class KnexQueueDriver {
40
62
  topic,
41
63
  payload,
42
64
  status: 'available',
65
+ attempts: 0,
66
+ available_at: toDatabaseTimestamp(this.db, Date.now()),
43
67
  }));
44
68
  await this.db(this.tableName).insert(rows);
45
69
  }
@@ -51,13 +75,15 @@ class KnexQueueDriver {
51
75
  .where('namespace', namespace)
52
76
  .andWhere('topic', topic)
53
77
  .andWhere(function () {
54
- this.where('status', 'available').orWhere(function () {
78
+ this.where(function () {
79
+ this.where('status', 'available').andWhere('available_at', '<=', now);
80
+ }).orWhere(function () {
55
81
  this.where('status', 'locked').andWhere('locked_at', '<', cutoff);
56
82
  });
57
83
  })
58
84
  .orderBy('id', 'asc')
59
85
  .limit(count)
60
- .select('id', 'payload');
86
+ .select('id', 'payload', 'attempts', 'last_error');
61
87
  const client = trx.client.config.client;
62
88
  if (client === 'mysql' || client === 'mysql2' || client === 'pg') {
63
89
  readyQuery = readyQuery.forUpdate().skipLocked();
@@ -70,13 +96,69 @@ class KnexQueueDriver {
70
96
  'id',
71
97
  rows.map((row) => row.id),
72
98
  )
73
- .update({ status: 'locked', locked_at: now });
74
- return rows.map((row) => ({ id: row.id, payload: row.payload }));
99
+ .update({
100
+ status: 'locked',
101
+ locked_at: now,
102
+ attempts: trx.raw('?? + 1', ['attempts']),
103
+ });
104
+ return rows.map((row) => ({
105
+ id: row.id,
106
+ payload: row.payload,
107
+ attempts: Number(row.attempts) + 1,
108
+ lastError: row.last_error,
109
+ }));
75
110
  });
76
111
  }
77
112
  async remove(namespace, ids) {
78
113
  if (ids.length === 0) return;
79
114
  await this.db(this.tableName).where('namespace', namespace).whereIn('id', ids).delete();
80
115
  }
116
+ async release(namespace, ids, { error, retryInMs = 0 } = {}) {
117
+ if (ids.length === 0) return;
118
+ await this.db(this.tableName)
119
+ .where({ namespace })
120
+ .whereIn('id', ids)
121
+ .update({
122
+ status: 'available',
123
+ locked_at: null,
124
+ available_at: toDatabaseTimestamp(this.db, Date.now() + retryInMs),
125
+ last_error: error ?? null,
126
+ });
127
+ }
128
+ async fail(namespace, ids, { error } = {}) {
129
+ if (ids.length === 0) return;
130
+ await this.db(this.tableName)
131
+ .where({ namespace })
132
+ .whereIn('id', ids)
133
+ .update({ status: 'failed', locked_at: null, last_error: error ?? null });
134
+ }
135
+ async retry(namespace, ids) {
136
+ if (ids.length === 0) return;
137
+ await this.db(this.tableName)
138
+ .where({ namespace, status: 'failed' })
139
+ .whereIn('id', ids)
140
+ .update({
141
+ status: 'available',
142
+ attempts: 0,
143
+ available_at: toDatabaseTimestamp(this.db, Date.now()),
144
+ last_error: null,
145
+ });
146
+ }
147
+ async list(namespace, topic, { status, limit = 100 } = {}) {
148
+ const query = this.db(this.tableName)
149
+ .where({ namespace, topic })
150
+ .orderBy('id', 'asc')
151
+ .limit(limit);
152
+ if (status) query.andWhere({ status });
153
+ return query.select('id', 'payload', 'status', 'attempts', 'available_at', 'last_error');
154
+ }
155
+ async count(namespace, topic) {
156
+ const rows = await this.db(this.tableName)
157
+ .where({ namespace, topic })
158
+ .groupBy('status')
159
+ .select('status')
160
+ .count({ count: '*' });
161
+ return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
162
+ }
81
163
  }
82
164
  export default KnexQueueDriver;
@@ -1,19 +1,26 @@
1
1
  import { REDIS_SCAN_COUNT } from '../../constants.js';
2
+
2
3
  const REQUEUE_EXPIRED_LUA = `
3
4
  local expired = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
4
- for i, member in ipairs(expired) do
5
- redis.call('LPUSH', KEYS[2], member)
5
+ for _, member in ipairs(expired) do
6
+ redis.call('ZADD', KEYS[2], ARGV[1], member)
6
7
  redis.call('ZREM', KEYS[1], member)
7
8
  end
8
9
  return #expired
9
10
  `;
11
+
10
12
  const POP_AND_LOCK_LUA = `
11
- local raw = redis.call('LPOP', KEYS[1])
12
- if raw then
13
- redis.call('ZADD', KEYS[2], ARGV[1], raw)
14
- end
15
- return raw
13
+ local rows = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 1)
14
+ if #rows == 0 then return nil end
15
+ local raw = rows[1]
16
+ redis.call('ZREM', KEYS[1], raw)
17
+ local item = cjson.decode(raw)
18
+ item.attempts = (item.attempts or 0) + 1
19
+ local updated = cjson.encode(item)
20
+ redis.call('ZADD', KEYS[2], ARGV[1], updated)
21
+ return updated
16
22
  `;
23
+
17
24
  class RedisQueueDriver {
18
25
  client;
19
26
  prefix;
@@ -21,66 +28,167 @@ class RedisQueueDriver {
21
28
  this.client = client;
22
29
  this.prefix = `${keyPrefix}queue:`;
23
30
  }
24
- listKey(namespace, topic) {
25
- return `${this.prefix}${namespace}:${topic}`;
31
+ availableKey(namespace, topic) {
32
+ return `${this.prefix}${namespace}:${topic}:available`;
26
33
  }
27
34
  lockedKey(namespace, topic) {
28
35
  return `${this.prefix}${namespace}:${topic}:locked`;
29
36
  }
37
+ failedKey(namespace, topic) {
38
+ return `${this.prefix}${namespace}:${topic}:failed`;
39
+ }
30
40
  idKey() {
31
41
  return `${this.prefix}id`;
32
42
  }
33
43
  async init() {}
34
44
 
45
+ item(id, payload, availableAt = Date.now()) {
46
+ return JSON.stringify({ id, payload, attempts: 0, availableAt, lastError: null });
47
+ }
35
48
  async push(namespace, topic, payload) {
36
49
  const id = await this.client.incr(this.idKey());
37
- const item = JSON.stringify({ id, payload });
38
- await this.client.rpush(this.listKey(namespace, topic), item);
50
+ const availableAt = Date.now();
51
+ await this.client.zadd(
52
+ this.availableKey(namespace, topic),
53
+ availableAt,
54
+ this.item(id, payload, availableAt),
55
+ );
39
56
  }
40
57
  async pushBulk(namespace, topic, payloads) {
41
- const listKey = this.listKey(namespace, topic);
42
- const idKey = this.idKey();
43
- const startId = await this.client.incrby(idKey, payloads.length);
44
- const items = payloads.map((payload, i) => {
45
- const id = startId - payloads.length + 1 + i;
46
- return JSON.stringify({ id, payload });
47
- });
48
- await this.client.rpush(listKey, ...items);
58
+ if (payloads.length === 0) return;
59
+ const startId = await this.client.incrby(this.idKey(), payloads.length);
60
+ const availableAt = Date.now();
61
+ const values = payloads.flatMap((payload, index) => [
62
+ availableAt,
63
+ this.item(startId - payloads.length + 1 + index, payload, availableAt),
64
+ ]);
65
+ await this.client.zadd(this.availableKey(namespace, topic), ...values);
49
66
  }
50
67
  async pop(namespace, topic, count, lockCooldownMs) {
51
68
  const results = [];
52
- const listKey = this.listKey(namespace, topic);
69
+ const availableKey = this.availableKey(namespace, topic);
53
70
  const lockedKey = this.lockedKey(namespace, topic);
54
71
  const now = Date.now();
55
- const cutoff = now - lockCooldownMs;
56
- await this.client.eval(REQUEUE_EXPIRED_LUA, 2, lockedKey, listKey, cutoff.toString());
57
- for (let i = 0; i < count; i++) {
58
- const raw = await this.client.eval(POP_AND_LOCK_LUA, 2, listKey, lockedKey, now.toString());
72
+ await this.client.eval(
73
+ REQUEUE_EXPIRED_LUA,
74
+ 2,
75
+ lockedKey,
76
+ availableKey,
77
+ String(now - lockCooldownMs),
78
+ );
79
+ for (let index = 0; index < count; index++) {
80
+ const raw = await this.client.eval(POP_AND_LOCK_LUA, 2, availableKey, lockedKey, String(now));
59
81
  if (!raw) break;
60
- const parsed = JSON.parse(raw);
61
- results.push({ id: parsed.id, payload: parsed.payload });
82
+ const item = JSON.parse(raw);
83
+ results.push(item);
62
84
  }
63
85
  return results;
64
86
  }
87
+ async findLocked(namespace, ids) {
88
+ const idSet = new Set(ids.map(Number));
89
+ const keys = await this.scanKeys(`${this.prefix}${namespace}:*:locked`);
90
+ const matches = [];
91
+ for (const key of keys) {
92
+ const members = await this.client.zrange(key, 0, -1);
93
+ for (const raw of members) {
94
+ const item = JSON.parse(raw);
95
+ if (idSet.has(Number(item.id))) matches.push({ key, raw, item });
96
+ }
97
+ }
98
+ return matches;
99
+ }
65
100
  async remove(namespace, ids) {
66
101
  if (ids.length === 0) return;
67
- const idSet = new Set(ids);
68
- const pattern = `${this.prefix}${namespace}:*:locked`;
69
- const keys = await this.scanKeys(pattern);
70
- for (const lockedKey of keys) {
71
- const members = await this.client.zrange(lockedKey, 0, -1);
72
- for (const member of members) {
73
- try {
74
- const parsed = JSON.parse(member);
75
- if (idSet.has(parsed.id)) {
76
- await this.client.zrem(lockedKey, member);
77
- }
78
- } catch {
79
- // Malformed locked member skip removal
80
- }
102
+ for (const { key, raw } of await this.findLocked(namespace, ids)) {
103
+ await this.client.zrem(key, raw);
104
+ }
105
+ }
106
+ async release(namespace, ids, { error, retryInMs = 0 } = {}) {
107
+ for (const { key, raw, item } of await this.findLocked(namespace, ids)) {
108
+ const availableKey = key.replace(/:locked$/, ':available');
109
+ const availableAt = Date.now() + retryInMs;
110
+ await this.client.zrem(key, raw);
111
+ await this.client.zadd(
112
+ availableKey,
113
+ availableAt,
114
+ JSON.stringify({ ...item, availableAt, lastError: error ?? null }),
115
+ );
116
+ }
117
+ }
118
+ async fail(namespace, ids, { error } = {}) {
119
+ for (const { key, raw, item } of await this.findLocked(namespace, ids)) {
120
+ const failedKey = key.replace(/:locked$/, ':failed');
121
+ await this.client.zrem(key, raw);
122
+ await this.client.hset(
123
+ failedKey,
124
+ String(item.id),
125
+ JSON.stringify({ ...item, status: 'failed', lastError: error ?? null }),
126
+ );
127
+ }
128
+ }
129
+ async retry(namespace, ids) {
130
+ const idSet = new Set(ids.map(Number));
131
+ const keys = await this.scanKeys(`${this.prefix}${namespace}:*:failed`);
132
+ for (const key of keys) {
133
+ const values = await this.client.hgetall(key);
134
+ for (const [id, raw] of Object.entries(values)) {
135
+ if (!idSet.has(Number(id))) continue;
136
+ const item = JSON.parse(raw);
137
+ await this.client.hdel(key, id);
138
+ const availableAt = Date.now();
139
+ await this.client.zadd(
140
+ key.replace(/:failed$/, ':available'),
141
+ availableAt,
142
+ JSON.stringify({
143
+ ...item,
144
+ status: undefined,
145
+ attempts: 0,
146
+ availableAt,
147
+ lastError: null,
148
+ }),
149
+ );
81
150
  }
82
151
  }
83
152
  }
153
+ async list(namespace, topic, { status, limit = 100 } = {}) {
154
+ const statuses = status ? [status] : ['available', 'locked', 'failed'];
155
+ const rows = [];
156
+ for (const current of statuses) {
157
+ let values;
158
+ if (current === 'failed') {
159
+ values = Object.values(await this.client.hgetall(this.failedKey(namespace, topic)));
160
+ } else {
161
+ const scored = await this.client.zrange(
162
+ current === 'locked'
163
+ ? this.lockedKey(namespace, topic)
164
+ : this.availableKey(namespace, topic),
165
+ 0,
166
+ limit - 1,
167
+ 'WITHSCORES',
168
+ );
169
+ values = [];
170
+ for (let index = 0; index < scored.length; index += 2) {
171
+ const item = JSON.parse(scored[index]);
172
+ values.push(
173
+ JSON.stringify({
174
+ ...item,
175
+ availableAt: current === 'available' ? Number(scored[index + 1]) : item.availableAt,
176
+ }),
177
+ );
178
+ }
179
+ }
180
+ rows.push(...values.map((raw) => ({ ...JSON.parse(raw), status: current })));
181
+ }
182
+ return rows.sort((a, b) => Number(a.id) - Number(b.id)).slice(0, limit);
183
+ }
184
+ async count(namespace, topic) {
185
+ const [available, locked, failed] = await Promise.all([
186
+ this.client.zcard(this.availableKey(namespace, topic)),
187
+ this.client.zcard(this.lockedKey(namespace, topic)),
188
+ this.client.hlen(this.failedKey(namespace, topic)),
189
+ ]);
190
+ return { available, locked, failed };
191
+ }
84
192
  async scanKeys(pattern) {
85
193
  const keys = [];
86
194
  let cursor = '0';
@@ -45,7 +45,7 @@ class Queue {
45
45
  this._log?.warn('Queue pop: corrupt JSON payload, using raw string', { id: r.id });
46
46
  data = r.payload;
47
47
  }
48
- return { id: r.id, data };
48
+ return { id: r.id, data, attempts: r.attempts ?? 0, lastError: r.lastError ?? null };
49
49
  });
50
50
  }
51
51
 
@@ -53,6 +53,34 @@ class Queue {
53
53
  await this._driver.remove(this._namespace, ids);
54
54
  }
55
55
 
56
+ async release(ids, options) {
57
+ await this._driver.release(this._namespace, ids, options);
58
+ }
59
+
60
+ async fail(ids, options) {
61
+ await this._driver.fail(this._namespace, ids, options);
62
+ }
63
+
64
+ async retry(ids) {
65
+ await this._driver.retry(this._namespace, ids);
66
+ }
67
+
68
+ async list(topic, options) {
69
+ const rows = await this._driver.list(this._namespace, topic, options);
70
+ return rows.map((row) => ({
71
+ id: row.id,
72
+ data: JSON.parse(row.payload),
73
+ status: row.status,
74
+ attempts: Number(row.attempts ?? 0),
75
+ availableAt: row.available_at ?? row.availableAt ?? null,
76
+ lastError: row.last_error ?? row.lastError ?? null,
77
+ }));
78
+ }
79
+
80
+ async count(topic) {
81
+ return this._driver.count(this._namespace, topic);
82
+ }
83
+
56
84
  /** Return a namespaced child that shares the same driver. */
57
85
  withNamespace(namespace) {
58
86
  const child = Object.create(Queue.prototype);
@@ -1,4 +1,5 @@
1
1
  import { ErrorCodes } from '../constants.js';
2
+ import { resolveRateLimitKey } from '../router/ratelimit-key.js';
2
3
  function defaultKeyFn(req) {
3
4
  const forwarded = req.headers['x-forwarded-for'];
4
5
  if (forwarded) return forwarded.split(',')[0].trim();
@@ -9,8 +10,9 @@ function createRateLimitMiddleware(options, store) {
9
10
  const windowMs = options.windowMs ?? 60000;
10
11
  const keyFn = options.keyFn ?? defaultKeyFn;
11
12
  return async (ctx) => {
12
- const key = keyFn(ctx.req);
13
- const result = await store.check(key, max, windowMs);
13
+ const resolved = resolveRateLimitKey({ _by: keyFn }, ctx.req);
14
+ if (!resolved) return;
15
+ const result = await store.check(resolved.value, max, windowMs);
14
16
  if (!result.allowed) {
15
17
  const resetTimestamp = Math.ceil((Date.now() + result.resetMs) / 1e3);
16
18
  const retryAfterSec = Math.ceil(result.resetMs / 1e3);
@@ -154,7 +154,7 @@ class ApiRouter {
154
154
 
155
155
  async executeRoute(route, reqInfo) {
156
156
  if (route.config._parsedRateLimit && this._redis) {
157
- const checked = await checkRouteRateLimits(this._redis.client, route.config, reqInfo.ip);
157
+ const checked = await checkRouteRateLimits(this._redis.client, route.config, reqInfo);
158
158
  if (!checked.allowed) {
159
159
  const { policy, result } = checked.selected;
160
160
  const retryAfter = Math.max(1, result.resetAt - Math.ceil(Date.now() / 1000));
@@ -274,9 +274,16 @@ class ApiRouter {
274
274
 
275
275
  // ── Rate limiting ──
276
276
  if (route.config._parsedRateLimit && this._redis) {
277
- const checked = await checkRouteRateLimits(this._redis.client, route.config, ip);
278
- const { policy, result } = checked.selected;
277
+ const checked = await checkRouteRateLimits(this._redis.client, route.config, {
278
+ ip,
279
+ query: req.query ?? {},
280
+ params,
281
+ body: req.body,
282
+ session: req.session,
283
+ headers: req.flatHeaders ?? flattenHeaders(req.headers),
284
+ });
279
285
  if (!checked.allowed) {
286
+ const { policy, result } = checked.selected;
280
287
  const retryAfter = Math.max(1, result.resetAt - Math.ceil(Date.now() / 1000));
281
288
  sendJson(
282
289
  res,
@@ -296,9 +303,12 @@ class ApiRouter {
296
303
  );
297
304
  return true;
298
305
  }
299
- res.setHeader('X-RateLimit-Limit', String(policy.max));
300
- res.setHeader('X-RateLimit-Remaining', String(result.remaining));
301
- res.setHeader('X-RateLimit-Reset', String(result.resetAt));
306
+ if (checked.selected) {
307
+ const { policy, result } = checked.selected;
308
+ res.setHeader('X-RateLimit-Limit', String(policy.max));
309
+ res.setHeader('X-RateLimit-Remaining', String(result.remaining));
310
+ res.setHeader('X-RateLimit-Reset', String(result.resetAt));
311
+ }
302
312
  }
303
313
 
304
314
  // ── Body parsing control ──
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const RATE_LIMIT_ROOTS = new Set(['body', 'query', 'params', 'session', 'headers']);
4
+
5
+ function hashRateLimitValue(value) {
6
+ return createHash('sha256').update(value.trim().toLowerCase()).digest('hex').slice(0, 32);
7
+ }
8
+
9
+ function isValidRateLimitBy(by) {
10
+ if (typeof by === 'function' || by === 'ip') return true;
11
+ if (typeof by !== 'string') return false;
12
+ const [root, ...segments] = by.split('.');
13
+ return RATE_LIMIT_ROOTS.has(root) && segments.length > 0 && segments.every(Boolean);
14
+ }
15
+
16
+ function readPath(req, path) {
17
+ const [root, ...segments] = path.split('.');
18
+ let value = req[root];
19
+ for (const segment of segments) {
20
+ if (value == null || typeof value !== 'object') return null;
21
+ value = value[segment];
22
+ }
23
+ return value;
24
+ }
25
+
26
+ function resolveRateLimitKey(policy, req) {
27
+ const by = policy._by ?? 'ip';
28
+ if (by === 'ip') return { scope: 'ip', value: req.ip ?? 'unknown' };
29
+ const raw = typeof by === 'function' ? by(req) : readPath(req, by);
30
+ if (raw == null) return null;
31
+ const value = String(raw).trim();
32
+ if (!value) return null;
33
+ return {
34
+ scope: typeof by === 'function' ? 'custom' : by,
35
+ value: hashRateLimitValue(value),
36
+ };
37
+ }
38
+
39
+ export { hashRateLimitValue, isValidRateLimitBy, resolveRateLimitKey };
@@ -38,8 +38,8 @@ local ttl = redis.call('TTL', KEYS[1])
38
38
  return {count, ttl}
39
39
  `;
40
40
 
41
- async function checkRateLimit(redisClient, { key, ip, max, windowSec }) {
42
- const redisKey = `rl:${key}:${windowSec}:${ip}`;
41
+ async function checkRateLimit(redisClient, { key, ip, scope = 'ip', value = ip, max, windowSec }) {
42
+ const redisKey = `rl:${key}:${windowSec}:${scope}:${value}`;
43
43
  const result = await redisClient.eval(LUA_INCR_EXPIRE, 1, redisKey, windowSec);
44
44
  const count = result[0];
45
45
  const ttl = result[1];
@@ -57,14 +57,18 @@ function configuredPolicies(config) {
57
57
  return [{ key: config.ratelimit.key, ...config._parsedRateLimit }];
58
58
  }
59
59
 
60
- async function checkRouteRateLimits(redisClient, config, ip) {
60
+ async function checkRouteRateLimits(redisClient, config, req) {
61
61
  const policies = configuredPolicies(config);
62
+ const applicable = policies
63
+ .map((policy) => ({ policy, resolved: resolveRateLimitKey(policy, req) }))
64
+ .filter(({ resolved }) => resolved !== null);
62
65
  const results = await Promise.all(
63
- policies.map(async (policy) => ({
66
+ applicable.map(async ({ policy, resolved }) => ({
64
67
  policy,
65
- result: await checkRateLimit(redisClient, { ...policy, ip }),
68
+ result: await checkRateLimit(redisClient, { ...policy, ...resolved }),
66
69
  })),
67
70
  );
71
+ if (results.length === 0) return { allowed: true, selected: null, results };
68
72
  const denied = results.filter(({ result }) => !result.allowed);
69
73
  const candidates = denied.length > 0 ? denied : results;
70
74
  const selected = candidates.reduce((strictest, candidate) => {
@@ -84,3 +88,4 @@ async function checkRouteRateLimits(redisClient, config, ip) {
84
88
  }
85
89
 
86
90
  export { checkRateLimit, checkRouteRateLimits, parseRateLimit };
91
+ import { resolveRateLimitKey } from './ratelimit-key.js';
@@ -1,6 +1,7 @@
1
1
  import { compileRoutePattern, extractRouteParams } from '#client/route-pattern.js';
2
2
  import { discoverModules } from '../discovery.js';
3
3
  import { parseRateLimit } from './ratelimit.js';
4
+ import { isValidRateLimitBy } from './ratelimit-key.js';
4
5
  const HTTP_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'];
5
6
  function filePathToPattern(relativePath) {
6
7
  let route = relativePath.replace(/\\/g, '/');
@@ -84,8 +85,16 @@ function buildRoutesFromModules(entries, { prefix = '', plugin } = {}) {
84
85
  `Route ${method} ${urlPattern} (${filePath}) ratelimit${suffix}.key must be a non-empty string`,
85
86
  );
86
87
  }
88
+ const by = policy.by ?? 'ip';
89
+ if (!isValidRateLimitBy(by)) {
90
+ const suffix = policies.length > 1 ? `[${index}]` : '';
91
+ throw new Error(
92
+ `Route ${method} ${urlPattern} (${filePath}) ratelimit${suffix}.by must be "ip", a request path, or a function`,
93
+ );
94
+ }
87
95
  return {
88
96
  key: policy.key ?? defaultRateLimitKey(relativePath, method, prefix),
97
+ _by: by,
89
98
  ...parseRateLimit(policy.limit),
90
99
  };
91
100
  });
@@ -170,6 +170,8 @@ async function testBoot(options) {
170
170
  });
171
171
  const baseUrl = `http://127.0.0.1:${app.port}`;
172
172
  const appContext = {
173
+ config: app.config,
174
+ env: app.config?.env,
173
175
  db: app.db,
174
176
  events: app.eventBus,
175
177
  queue: { push: async () => {}, pop: async () => [], remove: async () => {} },