@switchbot/openapi-cli 2.3.0 → 2.4.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.
@@ -1,6 +1,7 @@
1
1
  import { enumArg, stringArg } from '../utils/arg-parsers.js';
2
2
  import { printJson } from '../utils/output.js';
3
3
  import { getEffectiveCatalog } from '../devices/catalog.js';
4
+ import { loadCache } from '../devices/cache.js';
4
5
  function toSchemaEntry(e) {
5
6
  return {
6
7
  type: e.type,
@@ -24,6 +25,30 @@ function toSchemaCommand(c) {
24
25
  ...(c.exampleParams ? { exampleParams: c.exampleParams } : {}),
25
26
  };
26
27
  }
28
+ function toCompactEntry(e) {
29
+ return {
30
+ type: e.type,
31
+ category: e.category,
32
+ role: e.role ?? null,
33
+ readOnly: e.readOnly ?? false,
34
+ commands: e.commands.map((c) => ({
35
+ command: c.command,
36
+ parameter: c.parameter,
37
+ commandType: (c.commandType ?? 'command'),
38
+ idempotent: Boolean(c.idempotent),
39
+ destructive: Boolean(c.destructive),
40
+ })),
41
+ statusFields: e.statusFields ?? [],
42
+ };
43
+ }
44
+ function projectFields(entry, fields) {
45
+ const out = {};
46
+ for (const f of fields) {
47
+ if (f in entry)
48
+ out[f] = entry[f];
49
+ }
50
+ return out;
51
+ }
27
52
  export function registerSchemaCommand(program) {
28
53
  const ROLES = ['lighting', 'security', 'sensor', 'climate', 'media', 'cleaning', 'curtain', 'fan', 'power', 'hub', 'other'];
29
54
  const CATEGORIES = ['physical', 'ir'];
@@ -32,20 +57,41 @@ export function registerSchemaCommand(program) {
32
57
  .description('Export the device catalog as structured JSON (for agent prompts / tooling)');
33
58
  schema
34
59
  .command('export')
35
- .description('Print the full catalog as structured JSON (one object per type)')
60
+ .description('Print the catalog as structured JSON (one object per type)')
36
61
  .option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")', stringArg('--type'))
62
+ .option('--types <csv>', 'Restrict to multiple device types (comma-separated)', stringArg('--types'))
37
63
  .option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other', enumArg('--role', ROLES))
38
64
  .option('--category <cat>', 'Restrict to "physical" or "ir"', enumArg('--category', CATEGORIES))
65
+ .option('--compact', 'Drop descriptions/aliases/example params — emit ~60% smaller payload. Useful for agent prompts.')
66
+ .option('--used', 'Restrict to device types present in the local devices cache (run "devices list" first)')
67
+ .option('--project <csv>', 'Project per-type fields (e.g. --project type,commands,statusFields)', stringArg('--project'))
39
68
  .addHelpText('after', `
40
69
  Output is always JSON (this command ignores --format). The output is a
41
70
  catalog export — not a formal JSON Schema standard document — suitable for
42
71
  pre-baking LLM prompts or regenerating docs when the catalog changes.
43
72
 
73
+ Size tips:
74
+ --compact --used Smallest realistic payload for a given account
75
+ (< 15 KB on most accounts).
76
+ --fields type,commands Strip statusFields / role / etc. when only
77
+ commands are needed.
78
+ --type + --compact Inspect one type with minimum footprint.
79
+
80
+ Common top-level fields:
81
+ schemaVersion CLI schema version (stable for agent contracts)
82
+ data.version Catalog schema version
83
+ data.types Array of SchemaEntry (or CompactSchemaEntry with --compact)
84
+ data._fetchedAt CLI-added; present on live-query responses ('devices status'),
85
+ not on this offline export.
86
+
44
87
  Examples:
45
88
  $ switchbot schema export > catalog.json
46
- $ switchbot schema export --type Bot | jq '.types[0].commands'
47
- $ switchbot schema export --role lighting | jq '[.types[].type]'
89
+ $ switchbot schema export --compact --used | wc -c # small prompt-ready payload
90
+ $ switchbot schema export --type Bot | jq '.data.types[0].commands'
91
+ $ switchbot schema export --types "Bot,Curtain,Color Bulb"
92
+ $ switchbot schema export --role lighting | jq '[.data.types[].type]'
48
93
  $ switchbot schema export --role security --category physical
94
+ $ switchbot schema export --project type,commands,statusFields
49
95
  `)
50
96
  .action((options) => {
51
97
  const catalog = getEffectiveCatalog();
@@ -55,6 +101,11 @@ Examples:
55
101
  filtered = filtered.filter((e) => e.type.toLowerCase() === q ||
56
102
  (e.aliases ?? []).some((a) => a.toLowerCase() === q));
57
103
  }
104
+ if (options.types) {
105
+ const set = new Set(options.types.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean));
106
+ filtered = filtered.filter((e) => set.has(e.type.toLowerCase()) ||
107
+ (e.aliases ?? []).some((a) => set.has(a.toLowerCase())));
108
+ }
58
109
  if (options.role) {
59
110
  const q = options.role.toLowerCase();
60
111
  filtered = filtered.filter((e) => (e.role ?? 'other') === q);
@@ -63,11 +114,50 @@ Examples:
63
114
  const q = options.category.toLowerCase();
64
115
  filtered = filtered.filter((e) => e.category === q);
65
116
  }
117
+ if (options.used) {
118
+ const cache = loadCache();
119
+ if (cache) {
120
+ const usedTypes = new Set(Object.values(cache.devices).map((d) => d.type.toLowerCase()));
121
+ filtered = filtered.filter((e) => usedTypes.has(e.type.toLowerCase()) ||
122
+ (e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase())));
123
+ }
124
+ else {
125
+ filtered = [];
126
+ }
127
+ }
128
+ const mapped = options.compact
129
+ ? filtered.map(toCompactEntry)
130
+ : filtered.map(toSchemaEntry);
131
+ const projected = options.project
132
+ ? mapped.map((e) => projectFields(e, options.project.split(',').map((s) => s.trim()).filter(Boolean)))
133
+ : mapped;
66
134
  const payload = {
67
135
  version: '1.0',
68
- generatedAt: new Date().toISOString(),
69
- types: filtered.map(toSchemaEntry),
136
+ types: projected,
70
137
  };
138
+ if (!options.compact) {
139
+ payload.generatedAt = new Date().toISOString();
140
+ payload.cliAddedFields = [
141
+ {
142
+ field: '_fetchedAt',
143
+ appliesTo: ['devices status', 'devices describe'],
144
+ type: 'string (ISO-8601)',
145
+ description: 'CLI-synthesized timestamp indicating when this status response was fetched or served from the cache. Not part of the upstream SwitchBot API.',
146
+ },
147
+ {
148
+ field: 'replayed',
149
+ appliesTo: ['devices command (with --idempotency-key)'],
150
+ type: 'boolean',
151
+ description: 'CLI-synthesized flag — true when the response was served from the idempotency cache instead of re-executing the command.',
152
+ },
153
+ {
154
+ field: 'verification',
155
+ appliesTo: ['devices command'],
156
+ type: 'object',
157
+ description: 'CLI-synthesized receipt-acknowledgment metadata. For IR devices, verifiable:false signals that no device-side confirmation is possible.',
158
+ },
159
+ ];
160
+ }
71
161
  printJson(payload);
72
162
  });
73
163
  }
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,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
+ 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
@@ -18,6 +18,7 @@ import { registerSchemaCommand } from './commands/schema.js';
18
18
  import { registerHistoryCommand } from './commands/history.js';
19
19
  import { registerPlanCommand } from './commands/plan.js';
20
20
  import { registerCapabilitiesCommand } from './commands/capabilities.js';
21
+ import { registerAgentBootstrapCommand } from './commands/agent-bootstrap.js';
21
22
  const require = createRequire(import.meta.url);
22
23
  const { version: pkgVersion } = require('../package.json');
23
24
  const program = new Command();
@@ -26,7 +27,7 @@ const program = new Command();
26
27
  const TOP_LEVEL_COMMANDS = [
27
28
  'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp',
28
29
  'quota', 'catalog', 'cache', 'events', 'doctor', 'schema',
29
- 'history', 'plan', 'capabilities',
30
+ 'history', 'plan', 'capabilities', 'agent-bootstrap',
30
31
  ];
31
32
  const cacheModeArg = (value) => {
32
33
  if (value.startsWith('-')) {
@@ -44,8 +45,9 @@ program
44
45
  .description('Command-line tool for SwitchBot API v1.1')
45
46
  .version(pkgVersion)
46
47
  .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']))
48
+ .option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id, markdown', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id', 'markdown']))
48
49
  .option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS }))
50
+ .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
51
  .option('-v, --verbose', 'Log HTTP request/response details to stderr')
50
52
  .option('--dry-run', 'Print mutating requests without sending them (GETs still execute)')
51
53
  .option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)', intArg('--timeout', { min: 1 }))
@@ -76,6 +78,7 @@ registerSchemaCommand(program);
76
78
  registerHistoryCommand(program);
77
79
  registerPlanCommand(program);
78
80
  registerCapabilitiesCommand(program);
81
+ registerAgentBootstrapCommand(program);
79
82
  program.addHelpText('after', `
80
83
  Credentials:
81
84
  Provide SwitchBot API v1.1 credentials via either:
@@ -130,6 +133,13 @@ function applyExitOverride(cmd) {
130
133
  cmd.commands.forEach(applyExitOverride);
131
134
  }
132
135
  applyExitOverride(program);
136
+ // Enable "did you mean" suggestions across every subcommand, not just the root.
137
+ // Without this, `switchbot devices lst` fails without suggesting `list`.
138
+ function enableSuggestions(cmd) {
139
+ cmd.showSuggestionAfterError(true);
140
+ cmd.commands.forEach(enableSuggestions);
141
+ }
142
+ enableSuggestions(program);
133
143
  try {
134
144
  await program.parseAsync();
135
145
  }
@@ -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();