@switchbot/openapi-cli 2.3.0 → 2.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/dist/config.js CHANGED
@@ -3,6 +3,12 @@ import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { getConfigPath } from './utils/flags.js';
5
5
  import { getActiveProfile } from './lib/request-context.js';
6
+ function sanitizeOptionalString(v) {
7
+ if (typeof v !== 'string')
8
+ return undefined;
9
+ const trimmed = v.trim();
10
+ return trimmed ? trimmed : undefined;
11
+ }
6
12
  /**
7
13
  * Credential file resolution priority:
8
14
  * 1. --config <path> (absolute override — wins over everything)
@@ -85,15 +91,70 @@ export function tryLoadConfig() {
85
91
  return null;
86
92
  }
87
93
  }
88
- export function saveConfig(token, secret) {
94
+ export function saveConfig(token, secret, extras) {
89
95
  const file = configFilePath();
90
96
  const dir = path.dirname(file);
91
97
  if (!fs.existsSync(dir)) {
92
98
  fs.mkdirSync(dir, { recursive: true });
93
99
  }
94
- const cfg = { token, secret };
100
+ // Merge with existing file so label/limits/defaults aren't dropped when the
101
+ // user just rotates the token.
102
+ let existing = {};
103
+ if (fs.existsSync(file)) {
104
+ try {
105
+ existing = JSON.parse(fs.readFileSync(file, 'utf-8'));
106
+ }
107
+ catch {
108
+ existing = {};
109
+ }
110
+ }
111
+ const cfg = {
112
+ token,
113
+ secret,
114
+ ...(existing.label ? { label: existing.label } : {}),
115
+ ...(existing.description ? { description: existing.description } : {}),
116
+ ...(existing.limits ? { limits: existing.limits } : {}),
117
+ ...(existing.defaults ? { defaults: existing.defaults } : {}),
118
+ };
119
+ if (extras) {
120
+ const label = sanitizeOptionalString(extras.label);
121
+ const description = sanitizeOptionalString(extras.description);
122
+ if (label !== undefined)
123
+ cfg.label = label;
124
+ if (description !== undefined)
125
+ cfg.description = description;
126
+ if (extras.limits)
127
+ cfg.limits = { ...(cfg.limits ?? {}), ...extras.limits };
128
+ if (extras.defaults)
129
+ cfg.defaults = { ...(cfg.defaults ?? {}), ...extras.defaults };
130
+ }
95
131
  fs.writeFileSync(file, JSON.stringify(cfg, null, 2), { mode: 0o600 });
96
132
  }
133
+ /**
134
+ * Read a profile's metadata (label / description / limits / defaults) without
135
+ * exposing the token/secret. Returns null when the file is missing or invalid.
136
+ */
137
+ export function readProfileMeta(profile) {
138
+ const file = profile
139
+ ? profileFilePath(profile)
140
+ : path.join(os.homedir(), '.switchbot', 'config.json');
141
+ if (!fs.existsSync(file))
142
+ return null;
143
+ try {
144
+ const raw = fs.readFileSync(file, 'utf-8');
145
+ const cfg = JSON.parse(raw);
146
+ return {
147
+ label: cfg.label,
148
+ description: cfg.description,
149
+ limits: cfg.limits,
150
+ defaults: cfg.defaults,
151
+ path: file,
152
+ };
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
97
158
  export function showConfig() {
98
159
  const envToken = process.env.SWITCHBOT_TOKEN;
99
160
  const envSecret = process.env.SWITCHBOT_SECRET;
@@ -112,8 +173,16 @@ export function showConfig() {
112
173
  const raw = fs.readFileSync(file, 'utf-8');
113
174
  const cfg = JSON.parse(raw);
114
175
  console.log(`Credential source: ${file}`);
176
+ if (cfg.label)
177
+ console.log(`label : ${cfg.label}`);
178
+ if (cfg.description)
179
+ console.log(`desc : ${cfg.description}`);
115
180
  console.log(`token : ${maskCredential(cfg.token)}`);
116
181
  console.log(`secret: ${maskSecret(cfg.secret)}`);
182
+ if (cfg.limits?.dailyCap)
183
+ console.log(`limits: dailyCap=${cfg.limits.dailyCap}`);
184
+ if (cfg.defaults?.flags?.length)
185
+ console.log(`defaults: ${cfg.defaults.flags.join(' ')}`);
117
186
  }
118
187
  catch {
119
188
  console.error('Failed to read config file');
@@ -0,0 +1,138 @@
1
+ import fs from 'node:fs';
2
+ import readline from 'node:readline';
3
+ import { jsonlFilesForDevice, parseDurationToMs, resolveRange } from './history-query.js';
4
+ export const ALL_AGG_FNS = ['count', 'min', 'max', 'avg', 'sum', 'p50', 'p95'];
5
+ export const DEFAULT_AGGS = ['count', 'avg'];
6
+ export const DEFAULT_SAMPLE_CAP = 10_000;
7
+ export const MAX_SAMPLE_CAP = 100_000;
8
+ export async function aggregateDeviceHistory(deviceId, opts) {
9
+ const { fromMs, toMs } = resolveRange(opts);
10
+ const aggs = (opts.aggs && opts.aggs.length > 0) ? opts.aggs : [...DEFAULT_AGGS];
11
+ const needQuantile = aggs.includes('p50') || aggs.includes('p95');
12
+ let bucketMs = null;
13
+ if (opts.bucket !== undefined) {
14
+ bucketMs = parseDurationToMs(opts.bucket);
15
+ if (bucketMs === null) {
16
+ throw new Error(`Invalid --bucket "${opts.bucket}". Expected e.g. "15m", "1h", "1d".`);
17
+ }
18
+ }
19
+ const sampleCap = Math.max(1, Math.min(opts.maxBucketSamples ?? DEFAULT_SAMPLE_CAP, MAX_SAMPLE_CAP));
20
+ let partial = false;
21
+ const notes = [];
22
+ // bucketKey (epoch ms; 0 when no --bucket) → metric name → Acc
23
+ const buckets = new Map();
24
+ for (const file of jsonlFilesForDevice(deviceId)) {
25
+ try {
26
+ const st = fs.statSync(file);
27
+ if (st.mtimeMs < fromMs)
28
+ continue;
29
+ }
30
+ catch {
31
+ continue;
32
+ }
33
+ const stream = fs.createReadStream(file, { encoding: 'utf-8' });
34
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
35
+ for await (const line of rl) {
36
+ if (!line)
37
+ continue;
38
+ let rec;
39
+ try {
40
+ rec = JSON.parse(line);
41
+ }
42
+ catch {
43
+ continue;
44
+ }
45
+ const tMs = Date.parse(rec.t);
46
+ if (!Number.isFinite(tMs) || tMs < fromMs || tMs > toMs)
47
+ continue;
48
+ const key = bucketMs !== null ? Math.floor(tMs / bucketMs) * bucketMs : 0;
49
+ let bkt = buckets.get(key);
50
+ if (!bkt) {
51
+ bkt = new Map();
52
+ buckets.set(key, bkt);
53
+ }
54
+ for (const metric of opts.metrics) {
55
+ const v = rec.payload?.[metric];
56
+ if (typeof v !== 'number' || !Number.isFinite(v))
57
+ continue;
58
+ let acc = bkt.get(metric);
59
+ if (!acc) {
60
+ acc = {
61
+ min: v,
62
+ max: v,
63
+ sum: 0,
64
+ count: 0,
65
+ samples: needQuantile ? [] : null,
66
+ sampleCapHit: false,
67
+ };
68
+ bkt.set(metric, acc);
69
+ }
70
+ acc.min = Math.min(acc.min, v);
71
+ acc.max = Math.max(acc.max, v);
72
+ acc.sum += v;
73
+ acc.count += 1;
74
+ if (acc.samples) {
75
+ if (acc.samples.length < sampleCap) {
76
+ acc.samples.push(v);
77
+ }
78
+ else if (!acc.sampleCapHit) {
79
+ acc.sampleCapHit = true;
80
+ partial = true;
81
+ notes.push(`bucket ${new Date(key).toISOString()} metric ${metric}: sample cap ${sampleCap} reached, quantiles approximate`);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ return finalize(deviceId, opts, aggs, buckets, partial, notes, fromMs, toMs);
88
+ }
89
+ function finalize(deviceId, opts, aggs, buckets, partial, notes, fromMs, toMs) {
90
+ const fromIso = Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : new Date(0).toISOString();
91
+ const toIso = Number.isFinite(toMs) ? new Date(toMs).toISOString() : new Date(Date.now()).toISOString();
92
+ const keys = [...buckets.keys()].sort((a, b) => a - b);
93
+ const outBuckets = [];
94
+ for (const key of keys) {
95
+ const perMetric = buckets.get(key);
96
+ const metricsOut = {};
97
+ for (const [metric, acc] of perMetric.entries()) {
98
+ if (acc.count === 0)
99
+ continue;
100
+ const r = {};
101
+ if (aggs.includes('count'))
102
+ r.count = acc.count;
103
+ if (aggs.includes('min'))
104
+ r.min = acc.min;
105
+ if (aggs.includes('max'))
106
+ r.max = acc.max;
107
+ if (aggs.includes('avg'))
108
+ r.avg = acc.sum / acc.count;
109
+ if (aggs.includes('sum'))
110
+ r.sum = acc.sum;
111
+ if ((aggs.includes('p50') || aggs.includes('p95')) && acc.samples) {
112
+ const sorted = [...acc.samples].sort((a, b) => a - b);
113
+ if (aggs.includes('p50'))
114
+ r.p50 = sorted[Math.floor(0.5 * (sorted.length - 1))];
115
+ if (aggs.includes('p95'))
116
+ r.p95 = sorted[Math.floor(0.95 * (sorted.length - 1))];
117
+ }
118
+ metricsOut[metric] = r;
119
+ }
120
+ if (Object.keys(metricsOut).length === 0)
121
+ continue;
122
+ outBuckets.push({
123
+ t: new Date(key).toISOString(),
124
+ metrics: metricsOut,
125
+ });
126
+ }
127
+ return {
128
+ deviceId,
129
+ bucket: opts.bucket,
130
+ from: fromIso,
131
+ to: toIso,
132
+ metrics: [...opts.metrics],
133
+ aggs: [...aggs],
134
+ buckets: outBuckets,
135
+ partial,
136
+ notes,
137
+ };
138
+ }
@@ -0,0 +1,181 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import readline from 'node:readline';
5
+ const DEFAULT_LIMIT = 1000;
6
+ function historyDir() {
7
+ return path.join(os.homedir(), '.switchbot', 'device-history');
8
+ }
9
+ /**
10
+ * Parse a duration shortcut like "7d", "12h", "30m", "45s" into milliseconds.
11
+ * Returns null on malformed input (caller throws UsageError).
12
+ */
13
+ export function parseDurationToMs(spec) {
14
+ const m = spec.trim().match(/^(\d+)(ms|s|m|h|d)$/i);
15
+ if (!m)
16
+ return null;
17
+ const n = Number(m[1]);
18
+ const unit = m[2].toLowerCase();
19
+ const factor = unit === 'ms' ? 1 : unit === 's' ? 1_000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000;
20
+ return n * factor;
21
+ }
22
+ /** Parse ISO-8601 (with Z or offset) or Date-parseable string → ms, else null. */
23
+ export function parseInstantToMs(spec) {
24
+ const ms = Date.parse(spec);
25
+ return Number.isFinite(ms) ? ms : null;
26
+ }
27
+ export function resolveRange(opts) {
28
+ let fromMs = Number.NEGATIVE_INFINITY;
29
+ let toMs = Number.POSITIVE_INFINITY;
30
+ if (opts.since && (opts.from || opts.to)) {
31
+ throw new Error('--since is mutually exclusive with --from/--to.');
32
+ }
33
+ if (opts.since) {
34
+ const durMs = parseDurationToMs(opts.since);
35
+ if (durMs === null) {
36
+ throw new Error(`Invalid --since value "${opts.since}". Expected e.g. "30s", "15m", "1h", "7d".`);
37
+ }
38
+ fromMs = Date.now() - durMs;
39
+ }
40
+ else {
41
+ if (opts.from) {
42
+ const parsed = parseInstantToMs(opts.from);
43
+ if (parsed === null)
44
+ throw new Error(`Invalid --from value "${opts.from}". Expected ISO-8601 timestamp.`);
45
+ fromMs = parsed;
46
+ }
47
+ if (opts.to) {
48
+ const parsed = parseInstantToMs(opts.to);
49
+ if (parsed === null)
50
+ throw new Error(`Invalid --to value "${opts.to}". Expected ISO-8601 timestamp.`);
51
+ toMs = parsed;
52
+ }
53
+ if (fromMs > toMs)
54
+ throw new Error('--from must be <= --to.');
55
+ }
56
+ return { fromMs, toMs };
57
+ }
58
+ /** Return jsonl candidate files for a device, oldest-first. */
59
+ export function jsonlFilesForDevice(deviceId, baseDir = historyDir()) {
60
+ const out = [];
61
+ if (!fs.existsSync(baseDir))
62
+ return out;
63
+ // Oldest-first so range walks can bail early once a line overshoots `toMs`.
64
+ for (let i = 3; i >= 1; i--) {
65
+ const p = path.join(baseDir, `${deviceId}.jsonl.${i}`);
66
+ if (fs.existsSync(p))
67
+ out.push(p);
68
+ }
69
+ const current = path.join(baseDir, `${deviceId}.jsonl`);
70
+ if (fs.existsSync(current))
71
+ out.push(current);
72
+ return out;
73
+ }
74
+ function projectFields(record, fields) {
75
+ if (fields.length === 0)
76
+ return record;
77
+ const projected = {};
78
+ const payload = (record.payload ?? {});
79
+ for (const f of fields) {
80
+ if (f in payload)
81
+ projected[f] = payload[f];
82
+ }
83
+ return { t: record.t, topic: record.topic, deviceType: record.deviceType, payload: projected };
84
+ }
85
+ /**
86
+ * Stream-read the JSONL rotation files for `deviceId` and return records
87
+ * within [fromMs, toMs]. Parse failures are silently dropped (best-effort).
88
+ *
89
+ * Files whose mtime < fromMs are skipped whole (coarse but sound: the newest
90
+ * record in the file is <= mtime, so nothing in it can match).
91
+ */
92
+ export async function queryDeviceHistory(deviceId, opts = {}) {
93
+ const { fromMs, toMs } = resolveRange(opts);
94
+ const limit = Math.max(0, opts.limit ?? DEFAULT_LIMIT);
95
+ const fields = opts.fields ?? [];
96
+ const files = jsonlFilesForDevice(deviceId);
97
+ const out = [];
98
+ for (const file of files) {
99
+ try {
100
+ const stat = fs.statSync(file);
101
+ if (stat.mtimeMs < fromMs)
102
+ continue;
103
+ }
104
+ catch {
105
+ continue;
106
+ }
107
+ const stream = fs.createReadStream(file, { encoding: 'utf-8' });
108
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
109
+ for await (const line of rl) {
110
+ if (!line)
111
+ continue;
112
+ let rec;
113
+ try {
114
+ rec = JSON.parse(line);
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ const tMs = Date.parse(rec.t);
120
+ if (!Number.isFinite(tMs))
121
+ continue;
122
+ if (tMs < fromMs || tMs > toMs)
123
+ continue;
124
+ out.push(projectFields(rec, fields));
125
+ if (out.length >= limit) {
126
+ rl.close();
127
+ stream.destroy();
128
+ return out;
129
+ }
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+ export function queryDeviceHistoryStats(deviceId) {
135
+ const dir = historyDir();
136
+ const files = jsonlFilesForDevice(deviceId);
137
+ let totalBytes = 0;
138
+ let oldest = null;
139
+ let newest = null;
140
+ let count = 0;
141
+ for (const file of files) {
142
+ try {
143
+ totalBytes += fs.statSync(file).size;
144
+ }
145
+ catch { /* */ }
146
+ }
147
+ // Walk the oldest file's head + current file's tail for oldest/newest + count.
148
+ // Counting is O(records) here, acceptable for "stats" which isn't a hot path.
149
+ for (const file of files) {
150
+ try {
151
+ const lines = fs.readFileSync(file, 'utf-8').split('\n');
152
+ for (const line of lines) {
153
+ if (!line)
154
+ continue;
155
+ count += 1;
156
+ try {
157
+ const rec = JSON.parse(line);
158
+ const tMs = Date.parse(rec.t);
159
+ if (Number.isFinite(tMs)) {
160
+ if (oldest === null || tMs < oldest)
161
+ oldest = tMs;
162
+ if (newest === null || tMs > newest)
163
+ newest = tMs;
164
+ }
165
+ }
166
+ catch { /* */ }
167
+ }
168
+ }
169
+ catch { /* */ }
170
+ }
171
+ return {
172
+ deviceId,
173
+ fileCount: files.length,
174
+ totalBytes,
175
+ recordCount: count,
176
+ oldest: oldest !== null ? new Date(oldest).toISOString() : undefined,
177
+ newest: newest !== null ? new Date(newest).toISOString() : undefined,
178
+ jsonlFiles: files.map((f) => path.basename(f)),
179
+ historyDir: dir,
180
+ };
181
+ }
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, CommanderError, InvalidArgumentError } from 'commander';
3
3
  import { createRequire } from 'node:module';
4
+ import chalk from 'chalk';
4
5
  import { intArg, stringArg, enumArg } from './utils/arg-parsers.js';
5
6
  import { parseDurationToMs } from './utils/flags.js';
6
7
  import { registerConfigCommand } from './commands/config.js';
@@ -18,15 +19,21 @@ import { registerSchemaCommand } from './commands/schema.js';
18
19
  import { registerHistoryCommand } from './commands/history.js';
19
20
  import { registerPlanCommand } from './commands/plan.js';
20
21
  import { registerCapabilitiesCommand } from './commands/capabilities.js';
22
+ import { registerAgentBootstrapCommand } from './commands/agent-bootstrap.js';
21
23
  const require = createRequire(import.meta.url);
22
24
  const { version: pkgVersion } = require('../package.json');
25
+ // Early initialization: check for --no-color flag or NO_COLOR env var and disable chalk.
26
+ // This must happen before any commands run so all chalk output is affected.
27
+ if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) {
28
+ chalk.level = 0;
29
+ }
23
30
  const program = new Command();
24
31
  // Top-level subcommand names. Used by stringArg to produce clearer errors when
25
32
  // a value is omitted and the next argv token turns out to be a subcommand name.
26
33
  const TOP_LEVEL_COMMANDS = [
27
34
  'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp',
28
35
  'quota', 'catalog', 'cache', 'events', 'doctor', 'schema',
29
- 'history', 'plan', 'capabilities',
36
+ 'history', 'plan', 'capabilities', 'agent-bootstrap',
30
37
  ];
31
38
  const cacheModeArg = (value) => {
32
39
  if (value.startsWith('-')) {
@@ -43,9 +50,11 @@ program
43
50
  .name('switchbot')
44
51
  .description('Command-line tool for SwitchBot API v1.1')
45
52
  .version(pkgVersion)
53
+ .option('--no-color', 'Disable ANSI colors in output')
46
54
  .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)')
47
- .option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id']))
55
+ .option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id, markdown', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id', 'markdown']))
48
56
  .option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS }))
57
+ .option('--table-style <style>', 'Table rendering style: unicode (default on TTY), ascii (default on pipes), simple, markdown', enumArg('--table-style', ['unicode', 'ascii', 'simple', 'markdown']))
49
58
  .option('-v, --verbose', 'Log HTTP request/response details to stderr')
50
59
  .option('--dry-run', 'Print mutating requests without sending them (GETs still execute)')
51
60
  .option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)', intArg('--timeout', { min: 1 }))
@@ -76,6 +85,7 @@ registerSchemaCommand(program);
76
85
  registerHistoryCommand(program);
77
86
  registerPlanCommand(program);
78
87
  registerCapabilitiesCommand(program);
88
+ registerAgentBootstrapCommand(program);
79
89
  program.addHelpText('after', `
80
90
  Credentials:
81
91
  Provide SwitchBot API v1.1 credentials via either:
@@ -130,6 +140,13 @@ function applyExitOverride(cmd) {
130
140
  cmd.commands.forEach(applyExitOverride);
131
141
  }
132
142
  applyExitOverride(program);
143
+ // Enable "did you mean" suggestions across every subcommand, not just the root.
144
+ // Without this, `switchbot devices lst` fails without suggesting `list`.
145
+ function enableSuggestions(cmd) {
146
+ cmd.showSuggestionAfterError(true);
147
+ cmd.commands.forEach(enableSuggestions);
148
+ }
149
+ enableSuggestions(program);
133
150
  try {
134
151
  await program.parseAsync();
135
152
  }
@@ -124,7 +124,14 @@ export async function executeCommand(deviceId, cmd, parameter, commandType, clie
124
124
  throw err;
125
125
  }
126
126
  };
127
- return idempotencyCache.run(options?.idempotencyKey, execute);
127
+ const { result, replayed } = await idempotencyCache.run(options?.idempotencyKey, execute, { command: cmd, parameter });
128
+ if (!replayed)
129
+ return result;
130
+ // Cached hit — attach replayed marker without mutating the original.
131
+ if (result && typeof result === 'object') {
132
+ return { ...result, replayed: true };
133
+ }
134
+ return { value: result, replayed: true };
128
135
  }
129
136
  /**
130
137
  * Validate a command against the locally-cached device → catalog mapping.
@@ -1,11 +1,46 @@
1
1
  /**
2
2
  * In-memory LRU cache for idempotent request deduplication.
3
3
  * Caches the outcome of a keyed operation for 60 seconds;
4
- * duplicate keys within the window return the cached result without re-executing.
4
+ * duplicate keys within the window return the cached result (with a
5
+ * `replayed: true` marker). Duplicate keys within the window for a DIFFERENT
6
+ * (command, parameter) shape raise {@link IdempotencyConflictError}.
7
+ *
8
+ * Keys are stored in-memory as a SHA-256 fingerprint of the user-provided
9
+ * key — the original string never touches the Map keys, so a later heap dump
10
+ * or inadvertent log capture does not leak the raw token.
11
+ *
5
12
  * Process-local only — not shared across replicas.
6
13
  */
14
+ import crypto from 'node:crypto';
7
15
  const DEFAULT_TTL_MS = 60000; // 60 seconds
8
16
  const DEFAULT_MAX_ENTRIES = 1024;
17
+ export class IdempotencyConflictError extends Error {
18
+ key;
19
+ existingShape;
20
+ newShape;
21
+ constructor(message, key, existingShape, newShape) {
22
+ super(message);
23
+ this.key = key;
24
+ this.existingShape = existingShape;
25
+ this.newShape = newShape;
26
+ this.name = 'IdempotencyConflictError';
27
+ }
28
+ }
29
+ function hashKey(key) {
30
+ return crypto.createHash('sha256').update(key).digest('hex');
31
+ }
32
+ function shapeSignature(command, parameter) {
33
+ // Canonical-ish JSON — stable enough for object equality with no nested sort
34
+ // (callers can pass primitives or small objects).
35
+ let parm;
36
+ try {
37
+ parm = JSON.stringify(parameter ?? 'default');
38
+ }
39
+ catch {
40
+ parm = String(parameter);
41
+ }
42
+ return `${command}::${parm}`;
43
+ }
9
44
  export class IdempotencyCache {
10
45
  cache = new Map();
11
46
  ttlMs;
@@ -17,56 +52,55 @@ export class IdempotencyCache {
17
52
  /**
18
53
  * Execute fn if the key is not cached, or return the cached result if it is.
19
54
  * On new execution, caches the result for ttlMs.
55
+ *
56
+ * When `shape` is provided, a cached hit is validated against the original
57
+ * (command, parameter) fingerprint; mismatched shape raises
58
+ * {@link IdempotencyConflictError}.
59
+ *
60
+ * Returns a tuple-esque object with `replayed: true` when the cached
61
+ * result is served. The `result` field is the original cached value.
20
62
  */
21
- async run(key, fn) {
22
- // No key = always execute (not cached)
63
+ async run(key, fn, shape) {
23
64
  if (!key) {
24
- return fn();
65
+ const result = await fn();
66
+ return { result, replayed: false };
25
67
  }
68
+ const hashed = hashKey(key);
26
69
  const now = Date.now();
27
- const cached = this.cache.get(key);
28
- // Cached and not expired
70
+ const cached = this.cache.get(hashed);
71
+ const currentShape = shape ? shapeSignature(shape.command, shape.parameter) : '*';
29
72
  if (cached && cached.expiresAt > now) {
30
- return cached.result;
73
+ if (shape && cached.shape !== '*' && cached.shape !== currentShape) {
74
+ throw new IdempotencyConflictError(`idempotency_conflict: key was first used for ${cached.shape.replace('::', ' ')}; refusing new shape ${currentShape.replace('::', ' ')}`, '<redacted>', cached.shape, currentShape);
75
+ }
76
+ return { result: cached.result, replayed: true };
31
77
  }
32
- // Expired or uncached: execute
33
78
  const result = await fn();
34
- // Prune if over capacity (LRU: remove oldest entries)
35
79
  if (this.cache.size >= this.maxEntries) {
36
- const toRemove = Math.ceil(this.maxEntries * 0.1); // Remove 10%
80
+ const toRemove = Math.ceil(this.maxEntries * 0.1);
37
81
  let removed = 0;
38
82
  for (const [k, v] of this.cache.entries()) {
39
83
  if (removed >= toRemove)
40
84
  break;
41
- // Remove expired entries first, then oldest
42
85
  if (v.expiresAt <= now) {
43
86
  this.cache.delete(k);
44
87
  removed++;
45
88
  }
46
89
  }
47
- // If still over capacity, remove oldest insertion (Map is insertion-ordered)
48
90
  if (this.cache.size >= this.maxEntries) {
49
91
  const firstKey = this.cache.keys().next().value;
50
92
  if (firstKey)
51
93
  this.cache.delete(firstKey);
52
94
  }
53
95
  }
54
- // Cache the result
55
- this.cache.set(key, { result, expiresAt: now + this.ttlMs });
56
- return result;
96
+ this.cache.set(hashed, { result, expiresAt: now + this.ttlMs, shape: currentShape });
97
+ return { result, replayed: false };
57
98
  }
58
- /**
59
- * Clear all cached entries (mainly for testing).
60
- */
61
99
  clear() {
62
100
  this.cache.clear();
63
101
  }
64
- /**
65
- * Return the number of cached entries.
66
- */
67
102
  size() {
68
103
  return this.cache.size;
69
104
  }
70
105
  }
71
- // Global shared instance for the process
72
106
  export const idempotencyCache = new IdempotencyCache();