@danypops/tickets 0.8.7 → 0.9.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": "@danypops/tickets",
3
- "version": "0.8.7",
3
+ "version": "0.9.0",
4
4
  "description": "Unified CLI, daemon, and TypeScript library for issue tracking across GitHub, GitLab, and Jira.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,7 @@
27
27
  "@danypops/vehicle-core": "^0.12.3",
28
28
  "@danypops/vehicle-server": "^0.17.1",
29
29
  "@danypops/vehicle-client": "^0.5.0",
30
- "@danypops/enigma-client": "^0.3.0",
30
+ "@danypops/enigma-client": "^0.6.1",
31
31
  "@gitbeaker/rest": "^43.8.0",
32
32
  "commander": "^12.1.0",
33
33
  "jira.js": "^5.4.0",
@@ -236,6 +236,51 @@ const OPERATIONS: readonly OperationSpec[] = [
236
236
  properties: { name: stringProp, limit: numberProp },
237
237
  required: ["name"],
238
238
  },
239
+ {
240
+ action: "stage.add",
241
+ description:
242
+ "Stages a create/update/comment payload locally for review -- no live backend call. Free: never gated by the approval requirement stage.push carries.",
243
+ effect: "local-write",
244
+ properties: { payload: { type: "object" } },
245
+ required: ["payload"],
246
+ },
247
+ {
248
+ action: "stage.list",
249
+ description: "Lists every currently staged (not yet pushed) payload.",
250
+ effect: "read",
251
+ properties: {},
252
+ required: [],
253
+ },
254
+ {
255
+ action: "stage.show",
256
+ description: "Shows one staged payload by id.",
257
+ effect: "read",
258
+ properties: { id: stringProp },
259
+ required: ["id"],
260
+ },
261
+ {
262
+ action: "stage.patch",
263
+ description:
264
+ "Edits a staged payload's text fields in place before pushing it -- e.g. fixing a field that would otherwise fail backend validation.",
265
+ effect: "local-write",
266
+ properties: { id: stringProp, fields: { type: "object" } },
267
+ required: ["id", "fields"],
268
+ },
269
+ {
270
+ action: "stage.drop",
271
+ description: "Discards a staged payload without ever sending it to a live backend.",
272
+ effect: "local-write",
273
+ properties: { id: stringProp },
274
+ required: ["id"],
275
+ },
276
+ {
277
+ action: "stage.push",
278
+ description:
279
+ "Commits a staged payload to its live backend (create, update, or comment) -- a real, externally visible write, gated the same as issue.create/issue.update/issue.comment_add.",
280
+ effect: "external-write",
281
+ properties: { id: stringProp },
282
+ required: ["id"],
283
+ },
239
284
  ];
240
285
 
241
286
  /**
@@ -301,6 +346,13 @@ export function createTicketsVehicleRegistry(deps: Omit<TicketsAppDeps, "vehicle
301
346
  });
302
347
  // Every handler passes through the reviewed mapper above; unmatched failures stay redacted.
303
348
  registry.setExposeHandlerFailureDetails(true);
349
+ // Drafting (stage.add/list/show/patch/drop) stays local-write/read -- free,
350
+ // never gated. Committing a write to a live backend -- issue.create,
351
+ // issue.update, issue.comment_add, or stage.push landing a staged payload --
352
+ // is external-write, and this is what actually turns the gate on for that
353
+ // effect: registerVehicleTools' own ctx.ui.confirm() dance (see @danypops/vehicle-client-pi)
354
+ // then requires a real human decision before any of those four run.
355
+ registry.configureApprovals({ requireApprovalForEffects: ["destructive", "open-world", "external-write"] });
304
356
  for (const spec of OPERATIONS) {
305
357
  const operation = defineVehicleOperation({
306
358
  name: spec.action,
package/src/cli/index.ts CHANGED
@@ -15,6 +15,7 @@ import { promptMaskedSecret } from "../auth/masked-prompt.js";
15
15
  import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
16
16
  import type { CreateInput, ListFilter, Priority, Status, UpdateInput } from "../issue/issue.js";
17
17
  import { parseStatus } from "../issue/issue.js";
18
+ import type { StagePatchFields, StagePayload } from "../stage/store.js";
18
19
  import { installTicketsService, systemctlTickets, systemdUnitPath } from "./systemd-service.js";
19
20
  import { createTicketsClient, type TicketsRpcClient } from "./tickets-client.js";
20
21
 
@@ -299,6 +300,133 @@ discoverCmd
299
300
  );
300
301
  });
301
302
 
303
+ function definedEntriesOnly<T extends Record<string, unknown>>(input: T): Partial<T> {
304
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)) as Partial<T>;
305
+ }
306
+
307
+ function buildStagePayload(opts: {
308
+ kind?: string;
309
+ backend?: string;
310
+ ref?: string;
311
+ title?: string;
312
+ description?: string;
313
+ status?: string;
314
+ priority?: Priority;
315
+ label?: string[];
316
+ assignee?: string;
317
+ project?: string;
318
+ body?: string;
319
+ }): StagePayload {
320
+ if (opts.kind === "create") {
321
+ if (!opts.backend) throw new Error("stage add --kind create requires --backend");
322
+ if (!opts.title) throw new Error("stage add --kind create requires --title");
323
+ const input: CreateInput = definedEntriesOnly({
324
+ title: opts.title,
325
+ description: opts.description,
326
+ priority: opts.priority,
327
+ labels: opts.label,
328
+ assignee: opts.assignee,
329
+ project: opts.project,
330
+ }) as CreateInput;
331
+ return { kind: "create", backend: opts.backend, input };
332
+ }
333
+ if (opts.kind === "update") {
334
+ if (!opts.ref) throw new Error("stage add --kind update requires --ref");
335
+ const input: UpdateInput = definedEntriesOnly({
336
+ title: opts.title,
337
+ description: opts.description,
338
+ status: opts.status ? parseStatus(opts.status) : undefined,
339
+ priority: opts.priority,
340
+ labels: opts.label,
341
+ assignee: opts.assignee,
342
+ });
343
+ return { kind: "update", ref: opts.ref, input };
344
+ }
345
+ if (opts.kind === "comment") {
346
+ if (!opts.ref) throw new Error("stage add --kind comment requires --ref");
347
+ if (!opts.body) throw new Error("stage add --kind comment requires --body");
348
+ return { kind: "comment", ref: opts.ref, body: opts.body };
349
+ }
350
+ throw new Error(`stage add: unsupported --kind "${opts.kind}" (expected create, update, or comment)`);
351
+ }
352
+
353
+ const stage = program
354
+ .command("stage")
355
+ .description(
356
+ "stage a create/update/comment payload locally for review -- free, no live backend call. Committing it (stage push) requires approval, same as issue create/update/comment add.",
357
+ );
358
+
359
+ stage
360
+ .command("add")
361
+ .description("stage a new create, update, or comment payload")
362
+ .requiredOption("--kind <kind>", "create | update | comment")
363
+ .option("-b, --backend <name>", "backend name (kind=create)")
364
+ .option("--ref <ref>", "target issue ref (kind=update|comment)")
365
+ .option("--title <text>", "title (kind=create|update)")
366
+ .option("--description <text>", "description (kind=create|update)")
367
+ .option("--status <status>", "new status (kind=update)")
368
+ .option("--priority <priority>", "priority: none|urgent|high|medium|low (kind=create|update)")
369
+ .option("--label <label...>", "label(s) (kind=create|update)")
370
+ .option("--assignee <user>", "assignee (kind=create|update)")
371
+ .option("--project <project>", "project key/id (kind=create)")
372
+ .option("--body <text>", "comment body (kind=comment)")
373
+ .action(async (opts) => {
374
+ await withClient((client) => client.call("stage.add", { payload: buildStagePayload(opts) }));
375
+ });
376
+
377
+ stage
378
+ .command("list")
379
+ .description("list every currently staged payload")
380
+ .action(async () => {
381
+ await withClient((client) => client.call("stage.list", {}));
382
+ });
383
+
384
+ stage
385
+ .command("show <id>")
386
+ .description("show one staged payload by id")
387
+ .action(async (id: string) => {
388
+ await withClient((client) => client.call("stage.show", { id }));
389
+ });
390
+
391
+ stage
392
+ .command("patch <id>")
393
+ .description(
394
+ "edit a staged payload's text fields in place before pushing it -- e.g. fixing a field that would otherwise fail backend validation",
395
+ )
396
+ .option("--title <text>", "new title (create/update payloads)")
397
+ .option("--description <text>", "new description (create/update payloads)")
398
+ .option("--status <status>", "new status (update payloads)")
399
+ .option("--priority <priority>", "new priority (create/update payloads)")
400
+ .option("--label <label...>", "replace labels (create/update payloads)")
401
+ .option("--assignee <user>", "new assignee (create/update payloads)")
402
+ .option("--body <text>", "new comment body (comment payloads)")
403
+ .action(async (id: string, opts) => {
404
+ const fields: StagePatchFields = definedEntriesOnly({
405
+ title: opts.title,
406
+ description: opts.description,
407
+ status: opts.status ? parseStatus(opts.status) : undefined,
408
+ priority: opts.priority,
409
+ labels: opts.label,
410
+ assignee: opts.assignee,
411
+ body: opts.body,
412
+ });
413
+ await withClient((client) => client.call("stage.patch", { id, fields }));
414
+ });
415
+
416
+ stage
417
+ .command("drop <id>")
418
+ .description("discard a staged payload without ever sending it to a live backend")
419
+ .action(async (id: string) => {
420
+ await withClient((client) => client.call("stage.drop", { id }));
421
+ });
422
+
423
+ stage
424
+ .command("push <id>")
425
+ .description("commit a staged payload to its live backend -- requires approval, same as issue create/update/comment add")
426
+ .action(async (id: string) => {
427
+ await withClient((client) => client.call("stage.push", { id }));
428
+ });
429
+
302
430
  const daemon = program.command("daemon").description("manage the tickets daemon process");
303
431
 
304
432
  daemon
@@ -19,6 +19,7 @@ import { buildApp, type TicketsAppDeps } from "../rpc/server.js";
19
19
  import { FOCUS_MIGRATIONS, FocusStore } from "../sqlite/focus.js";
20
20
  import { LEDGER_MIGRATIONS, Ledger } from "../sqlite/ledger.js";
21
21
  import { SAVED_QUERY_MIGRATIONS, SavedQueryStore } from "../sqlite/saved-queries.js";
22
+ import { StageStore } from "../stage/store.js";
22
23
  import { createSyncTask } from "./poller.js";
23
24
 
24
25
  export interface BootstrapOptions {
@@ -52,6 +53,7 @@ export interface BootstrappedDaemon {
52
53
  ledger: Ledger;
53
54
  focusStore: FocusStore;
54
55
  queries: SavedQueryStore;
56
+ stageStore: StageStore;
55
57
  service: TicketService;
56
58
  options: StartDaemonOptions;
57
59
  }
@@ -67,6 +69,7 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
67
69
  const ledger = new Ledger(db);
68
70
  const focusStore = new FocusStore(db);
69
71
  const queries = new SavedQueryStore(db);
72
+ const stageStore = new StageStore();
70
73
  const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
71
74
  const config = opts.config ?? loadConfig();
72
75
  const buildRepos = opts.buildRepositories ?? buildRepositories;
@@ -84,6 +87,7 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
84
87
  ledger,
85
88
  focusStore,
86
89
  queries,
90
+ stageStore,
87
91
  token,
88
92
  version,
89
93
  logger,
@@ -122,6 +126,7 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
122
126
  ledger,
123
127
  focusStore,
124
128
  queries,
129
+ stageStore,
125
130
  token,
126
131
  version,
127
132
  logger,
@@ -133,5 +138,5 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
133
138
  },
134
139
  };
135
140
 
136
- return { db, ledger, focusStore, queries, service, options };
141
+ return { db, ledger, focusStore, queries, stageStore, service, options };
137
142
  }
@@ -2,10 +2,12 @@ import { AuthRequiredError, IssueNotFoundError } from "../issue/errors.js";
2
2
  import { NotSupportedError, UnknownBackendError } from "../issue/service.js";
3
3
  import { FocusError } from "../sqlite/focus.js";
4
4
  import { SavedQueryNotFoundError } from "../sqlite/saved-queries.js";
5
+ import { StagedItemNotFoundError } from "../stage/store.js";
5
6
 
6
7
  /** Returns the legacy HTTP status only for reviewed business errors; unknown failures stay unclassified. */
7
8
  export function statusForKnownTicketError(error: unknown): number | undefined {
8
- if (error instanceof IssueNotFoundError || error instanceof SavedQueryNotFoundError) return 404;
9
+ if (error instanceof IssueNotFoundError || error instanceof SavedQueryNotFoundError || error instanceof StagedItemNotFoundError)
10
+ return 404;
9
11
  if (error instanceof UnknownBackendError || error instanceof NotSupportedError || error instanceof FocusError) return 400;
10
12
  if (error instanceof AuthRequiredError) return 422;
11
13
  return undefined;
package/src/rpc/ops.ts CHANGED
@@ -8,6 +8,7 @@ import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../is
8
8
  import type { Template } from "../issue/template.js";
9
9
  import type { TicketFocusState } from "../sqlite/focus.js";
10
10
  import type { SavedQuery } from "../sqlite/saved-queries.js";
11
+ import type { StagedItem, StagePatchFields, StagePayload } from "../stage/store.js";
11
12
 
12
13
  export type TicketOperation =
13
14
  | "backends.list"
@@ -35,6 +36,12 @@ export type TicketOperation =
35
36
  | "query.list"
36
37
  | "query.remove"
37
38
  | "query.run"
39
+ | "stage.add"
40
+ | "stage.list"
41
+ | "stage.show"
42
+ | "stage.patch"
43
+ | "stage.drop"
44
+ | "stage.push"
38
45
  | "daemon.shutdown";
39
46
 
40
47
  export interface TicketOpInputs extends Record<TicketOperation, unknown> {
@@ -63,9 +70,18 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
63
70
  "query.list": Record<string, never>;
64
71
  "query.remove": { name: string };
65
72
  "query.run": { name: string; limit?: number };
73
+ "stage.add": { payload: StagePayload };
74
+ "stage.list": Record<string, never>;
75
+ "stage.show": { id: string };
76
+ "stage.patch": { id: string; fields: StagePatchFields };
77
+ "stage.drop": { id: string };
78
+ "stage.push": { id: string };
66
79
  "daemon.shutdown": Record<string, never>;
67
80
  }
68
81
 
82
+ /** stage.push's own output shape -- whichever real op the staged payload's kind maps to (issue.create/issue.update/issue.comment_add). */
83
+ export type StagePushResult = { issue: Issue } | { comment: Comment };
84
+
69
85
  export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
70
86
  "backends.list": { backends: { name: string; supportsRawQuery: boolean }[] };
71
87
  "issue.list": { issues: Issue[] };
@@ -92,6 +108,12 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
92
108
  "query.list": { queries: SavedQuery[] };
93
109
  "query.remove": { removed: boolean };
94
110
  "query.run": { issues: Issue[] };
111
+ "stage.add": { item: StagedItem };
112
+ "stage.list": { items: StagedItem[] };
113
+ "stage.show": { item: StagedItem };
114
+ "stage.patch": { item: StagedItem };
115
+ "stage.drop": { dropped: boolean };
116
+ "stage.push": { result: StagePushResult };
95
117
  "daemon.shutdown": { stopping: true };
96
118
  }
97
119
 
@@ -121,6 +143,12 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
121
143
  "query.list",
122
144
  "query.remove",
123
145
  "query.run",
146
+ "stage.add",
147
+ "stage.list",
148
+ "stage.show",
149
+ "stage.patch",
150
+ "stage.drop",
151
+ "stage.push",
124
152
  "daemon.shutdown",
125
153
  ];
126
154
 
package/src/rpc/server.ts CHANGED
@@ -16,8 +16,9 @@ import type { TicketService } from "../issue/service.js";
16
16
  import { FocusError, type FocusStore } from "../sqlite/focus.js";
17
17
  import type { Ledger } from "../sqlite/ledger.js";
18
18
  import { SavedQueryNotFoundError, type SavedQueryStore } from "../sqlite/saved-queries.js";
19
+ import type { StagePayload, StageStore } from "../stage/store.js";
19
20
  import { statusForKnownTicketError } from "./error-status.js";
20
- import { TICKET_OPERATIONS, type TicketOperation, type TicketOpInputs, type TicketOpOutputs } from "./ops.js";
21
+ import { type StagePushResult, TICKET_OPERATIONS, type TicketOperation, type TicketOpInputs, type TicketOpOutputs } from "./ops.js";
21
22
 
22
23
  export interface TicketsAppDeps {
23
24
  service: TicketService;
@@ -43,6 +44,7 @@ export interface TicketsAppDeps {
43
44
  * from this file).
44
45
  */
45
46
  vehicleRegistry: VehicleRegistry;
47
+ stageStore: StageStore;
46
48
  }
47
49
 
48
50
  // Narrower than TicketsAppDeps on purpose: no real handler reads
@@ -104,6 +106,20 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
104
106
  if (!saved) throw new SavedQueryNotFoundError(input.name);
105
107
  return { issues: await deps.service.runQuery(saved.backend, saved.query, input.limit) };
106
108
  },
109
+ "stage.add": async (deps, input) => ({ item: deps.stageStore.add(input.payload) }),
110
+ "stage.list": async (deps) => ({ items: deps.stageStore.list() }),
111
+ "stage.show": async (deps, input) => ({ item: deps.stageStore.show(input.id) }),
112
+ "stage.patch": async (deps, input) => ({ item: deps.stageStore.patch(input.id, input.fields) }),
113
+ "stage.drop": async (deps, input) => ({ dropped: deps.stageStore.drop(input.id) }),
114
+ "stage.push": async (deps, input) => {
115
+ // Left staged (not dropped) on a live-write failure -- e.g. a field that
116
+ // failed backend validation -- so it can be patched and retried, per
117
+ // this feature's own emcee-ported motivation.
118
+ const item = deps.stageStore.show(input.id);
119
+ const result = await pushStagedPayload(deps, item.payload);
120
+ deps.stageStore.drop(item.id);
121
+ return { result };
122
+ },
107
123
  "daemon.shutdown": async (deps) => {
108
124
  // Deferred so this handler's own response has already been handed back
109
125
  // to Bun.serve before the process starts tearing down.
@@ -112,6 +128,22 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
112
128
  },
113
129
  };
114
130
 
131
+ /** stage.push's real dispatch: whichever live op a staged payload's kind maps to, never reimplemented -- same TicketService methods issue.create/issue.update/issue.comment_add already call. */
132
+ async function pushStagedPayload(deps: Omit<TicketsAppDeps, "vehicleRegistry">, payload: StagePayload): Promise<StagePushResult> {
133
+ switch (payload.kind) {
134
+ case "create":
135
+ return { issue: await deps.service.create(payload.backend, payload.input) };
136
+ case "update":
137
+ return { issue: await deps.service.update(payload.ref, payload.input) };
138
+ case "comment":
139
+ return { comment: await deps.service.addComment(payload.ref, payload.body) };
140
+ default: {
141
+ const _exhaustive: never = payload;
142
+ throw new Error(`unhandled staged payload kind: ${JSON.stringify(_exhaustive)}`);
143
+ }
144
+ }
145
+ }
146
+
115
147
  function isTicketOperation(value: unknown): value is TicketOperation {
116
148
  return typeof value === "string" && (TICKET_OPERATIONS as string[]).includes(value);
117
149
  }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Staging — a pre-submission cache for any textual write (a new issue, an
3
+ * update, a comment) so it can be reviewed and edited before it ever touches
4
+ * a live backend. Ported from `~/Workspace/emcee`'s working stage
5
+ * capability, generalized from issue-creation-only to any of the three
6
+ * payload kinds via a discriminated union, and with a generic patch()
7
+ * instead of one hand-rolled field list per kind.
8
+ *
9
+ * In-memory, not persisted: a daemon restart drops whatever was staged,
10
+ * matching emcee's own design. Bounded by both a TTL (expired items are
11
+ * evicted lazily on the next access) and a max item count (the oldest item
12
+ * is evicted to make room for a new one past that count) — never an
13
+ * unbounded map.
14
+ */
15
+ import { randomUUID } from "node:crypto";
16
+ import type { CreateInput, UpdateInput } from "../issue/issue.js";
17
+
18
+ export const STAGE_TTL_MS = 30 * 60_000;
19
+ export const STAGE_MAX_ITEMS = 50;
20
+
21
+ export interface StageCreatePayload {
22
+ kind: "create";
23
+ backend: string;
24
+ input: CreateInput;
25
+ }
26
+
27
+ export interface StageUpdatePayload {
28
+ kind: "update";
29
+ ref: string;
30
+ input: UpdateInput;
31
+ }
32
+
33
+ export interface StageCommentPayload {
34
+ kind: "comment";
35
+ ref: string;
36
+ body: string;
37
+ }
38
+
39
+ export type StagePayload = StageCreatePayload | StageUpdatePayload | StageCommentPayload;
40
+
41
+ export interface StagedItem {
42
+ id: string;
43
+ payload: StagePayload;
44
+ createdAt: string;
45
+ expiresAt: string;
46
+ }
47
+
48
+ export class StagedItemNotFoundError extends Error {
49
+ constructor(id: string) {
50
+ super(`no staged item "${id}" (it may have expired -- staged items lapse after 30 minutes)`);
51
+ this.name = "StagedItemNotFoundError";
52
+ }
53
+ }
54
+
55
+ /** Only the free-text fields a patch may override, never kind/backend/ref: those are the staged item's identity, not text content to edit. */
56
+ export type StagePatchFields = Partial<CreateInput> & Partial<UpdateInput> & { body?: string };
57
+
58
+ function applyPatch(payload: StagePayload, fields: StagePatchFields): StagePayload {
59
+ if (payload.kind === "comment") {
60
+ return typeof fields.body === "string" ? { ...payload, body: fields.body } : payload;
61
+ }
62
+ const { body: _ignoredBody, ...inputFields } = fields;
63
+ return { ...payload, input: { ...payload.input, ...inputFields } } as StagePayload;
64
+ }
65
+
66
+ export class StageStore {
67
+ private readonly items = new Map<string, StagedItem>();
68
+
69
+ constructor(private readonly now: () => Date = () => new Date()) {}
70
+
71
+ add(payload: StagePayload): StagedItem {
72
+ this.evictExpired();
73
+ if (this.items.size >= STAGE_MAX_ITEMS) this.evictOldest();
74
+
75
+ const createdAt = this.now();
76
+ const item: StagedItem = {
77
+ id: randomUUID(),
78
+ payload,
79
+ createdAt: createdAt.toISOString(),
80
+ expiresAt: new Date(createdAt.getTime() + STAGE_TTL_MS).toISOString(),
81
+ };
82
+ this.items.set(item.id, item);
83
+ return item;
84
+ }
85
+
86
+ list(): StagedItem[] {
87
+ this.evictExpired();
88
+ return [...this.items.values()];
89
+ }
90
+
91
+ show(id: string): StagedItem {
92
+ this.evictExpired();
93
+ const item = this.items.get(id);
94
+ if (!item) throw new StagedItemNotFoundError(id);
95
+ return item;
96
+ }
97
+
98
+ patch(id: string, fields: StagePatchFields): StagedItem {
99
+ const item = this.show(id);
100
+ const patched: StagedItem = { ...item, payload: applyPatch(item.payload, fields) };
101
+ this.items.set(id, patched);
102
+ return patched;
103
+ }
104
+
105
+ /** Idempotent -- dropping an id that's already gone (removed, expired) is a no-op, not an error, matching this codebase's own undepend/uncontain convention. */
106
+ drop(id: string): boolean {
107
+ this.evictExpired();
108
+ return this.items.delete(id);
109
+ }
110
+
111
+ private evictExpired(): void {
112
+ const now = this.now().getTime();
113
+ for (const [id, item] of this.items) {
114
+ if (new Date(item.expiresAt).getTime() <= now) this.items.delete(id);
115
+ }
116
+ }
117
+
118
+ private evictOldest(): void {
119
+ const oldest = [...this.items.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt))[0];
120
+ if (oldest) this.items.delete(oldest.id);
121
+ }
122
+ }