@hotmeshio/hotmesh 0.22.2 → 0.22.4

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.
@@ -395,6 +395,12 @@ type WorkflowDataType = {
395
395
  type Connection = ProviderConfig | ProvidersConfig;
396
396
  type ClientConfig = {
397
397
  connection: Connection;
398
+ /**
399
+ * Optional system-event sink. When set, `client.escalations.*` operations
400
+ * call `events.publish` post-commit from the invoking process. Wires the
401
+ * same hook as `HotMeshConfig.events` for direct-client callers.
402
+ */
403
+ events?: import('./system_events').EventsConfig;
398
404
  };
399
405
  type Registry = {
400
406
  [key: string]: Function;
@@ -442,6 +448,11 @@ type WorkerConfig = {
442
448
  user: string;
443
449
  password: string;
444
450
  };
451
+ /**
452
+ * Optional system-event sink. When set, the worker fires `events.publish`
453
+ * on `system.worker.{taskQueue}.started` and `system.worker.{taskQueue}.stopped`.
454
+ */
455
+ events?: import('./system_events').EventsConfig;
445
456
  };
446
457
  type FindWhereQuery = {
447
458
  field: string;
@@ -54,6 +54,8 @@ export interface EscalationEntry {
54
54
  trace_id: string | null;
55
55
  span_id: string | null;
56
56
  expires_at: Date | null;
57
+ /** Nullable passthrough column — populated when downstream needs task-level context. */
58
+ task_id: string | null;
57
59
  created_at: Date;
58
60
  updated_at: Date;
59
61
  /** Computed by list(): true when the row is claimable (no active assignee or expired claim). */
@@ -68,6 +70,7 @@ export interface EscalationEntry {
68
70
  export type ClaimEscalationResult = {
69
71
  ok: true;
70
72
  entry: EscalationEntry;
73
+ isExtension: boolean;
71
74
  } | {
72
75
  ok: false;
73
76
  reason: 'not-found' | 'conflict';
@@ -89,18 +92,21 @@ export type ClaimByMetadataResult = {
89
92
  };
90
93
  export type ResolveEscalationResult = {
91
94
  ok: true;
95
+ entry: EscalationEntry;
92
96
  } | {
93
97
  ok: false;
94
98
  reason: 'not-found' | 'already-resolved' | 'already-cancelled';
95
99
  };
96
100
  export type ReleaseEscalationResult = {
97
101
  ok: true;
102
+ entry: EscalationEntry;
98
103
  } | {
99
104
  ok: false;
100
105
  reason: 'not-found' | 'wrong-assignee';
101
106
  };
102
107
  export type CancelEscalationResult = {
103
108
  ok: true;
109
+ entry: EscalationEntry;
104
110
  } | {
105
111
  ok: false;
106
112
  reason: 'not-found' | 'already-terminal';
@@ -119,11 +125,51 @@ export interface ListEscalationsParams {
119
125
  originId?: string;
120
126
  /** When true, returns only rows without an active claim. When false, returns only actively claimed rows. */
121
127
  available?: boolean;
128
+ /** Exact priority match. */
129
+ priority?: number;
130
+ /** JSONB containment filter — rows whose `metadata` contains all provided keys/values. */
131
+ metadata?: Record<string, unknown>;
132
+ /** Filter by a set of UUIDs. */
133
+ ids?: string[];
134
+ /** Filter by `task_id` column. */
135
+ taskId?: string;
122
136
  sortBy?: 'created_at' | 'priority' | 'updated_at';
123
137
  sortOrder?: 'asc' | 'desc';
138
+ /**
139
+ * Multi-column sort. When provided, supersedes `sortBy`/`sortOrder`.
140
+ * Columns are applied left to right.
141
+ */
142
+ orderBy?: Array<{
143
+ column: 'priority' | 'created_at' | 'updated_at' | 'resolved_at' | 'role' | 'type';
144
+ direction: 'asc' | 'desc';
145
+ }>;
124
146
  limit?: number;
125
147
  offset?: number;
126
148
  }
149
+ export interface StatsEscalationsParams {
150
+ namespace?: string;
151
+ /** RBAC scope — when an empty array is provided, all counts are zero. */
152
+ roles?: string[];
153
+ /** Counting window for created/resolved. Default: '24h'. */
154
+ period?: '1h' | '24h' | '7d' | '30d';
155
+ }
156
+ export interface EscalationStats {
157
+ pending: number;
158
+ claimed: number;
159
+ created: number;
160
+ resolved: number;
161
+ by_role: Array<{
162
+ role: string;
163
+ pending: number;
164
+ claimed: number;
165
+ }>;
166
+ by_type: Array<{
167
+ type: string;
168
+ pending: number;
169
+ claimed: number;
170
+ resolved: number;
171
+ }>;
172
+ }
127
173
  export interface CreateEscalationParams {
128
174
  namespace?: string;
129
175
  appId?: string;
@@ -144,6 +190,7 @@ export interface CreateEscalationParams {
144
190
  createdBy?: string;
145
191
  traceId?: string;
146
192
  spanId?: string;
193
+ taskId?: string;
147
194
  escalationPayload?: Record<string, unknown>;
148
195
  metadata?: Record<string, unknown>;
149
196
  envelope?: Record<string, unknown>;
@@ -161,6 +208,7 @@ export interface UpdateEscalationParams {
161
208
  description?: string;
162
209
  priority?: number;
163
210
  role?: string;
211
+ taskId?: string;
164
212
  /** Merged into existing metadata (keys overwritten, others preserved) */
165
213
  metadata?: Record<string, unknown>;
166
214
  /** Replaces existing envelope */
@@ -195,6 +243,8 @@ export interface ClaimByMetadataParams {
195
243
  assignee?: string;
196
244
  durationMinutes?: number;
197
245
  roles?: string[];
246
+ /** Merged (not replaced) into the claimed row's metadata in the same atomic UPDATE. */
247
+ metadata?: Record<string, unknown>;
198
248
  }
199
249
  export interface ReleaseEscalationParams {
200
250
  id: string;
@@ -219,6 +269,27 @@ export interface EscalateToRoleParams {
219
269
  targetRole: string;
220
270
  namespace?: string;
221
271
  }
272
+ export interface ClaimManyParams {
273
+ ids: string[];
274
+ namespace?: string;
275
+ assignee: string;
276
+ durationMinutes?: number;
277
+ }
278
+ export interface EscalateManyToRoleParams {
279
+ ids: string[];
280
+ namespace?: string;
281
+ targetRole: string;
282
+ }
283
+ export interface UpdateManyPriorityParams {
284
+ ids: string[];
285
+ namespace?: string;
286
+ priority: number;
287
+ }
288
+ export interface ResolveManyParams {
289
+ ids: string[];
290
+ namespace?: string;
291
+ resolverPayload?: Record<string, unknown>;
292
+ }
222
293
  /**
223
294
  * Full-fidelity migration params. Extends `CreateEscalationParams` with:
224
295
  * - `id` (required) — preserves the original UUID; no auto-generation
@@ -309,6 +309,23 @@ type HotMeshConfig = {
309
309
  taskQueue?: string;
310
310
  engine?: HotMeshEngine;
311
311
  workers?: HotMeshWorker[];
312
+ /**
313
+ * Optional system-event sink. When provided, the engine calls
314
+ * `events.publish` post-commit for each durable transition it performs
315
+ * (escalation lifecycle, engine start/stop, deploy). Fire-and-forget.
316
+ *
317
+ * @example
318
+ * ```typescript
319
+ * const hotMesh = await HotMesh.init({
320
+ * appId: 'myapp',
321
+ * engine: { connection: { class: Postgres, options: { ... } } },
322
+ * events: {
323
+ * publish: (e) => nats.publish(e.type, JSON.stringify(e)),
324
+ * },
325
+ * });
326
+ * ```
327
+ */
328
+ events?: import('./system_events').EventsConfig;
312
329
  };
313
330
  type HotMeshGraph = {
314
331
  /**
@@ -26,3 +26,4 @@ export { context, Context, Counter, Meter, metrics, propagation, SpanContext, Sp
26
26
  export { WorkListTaskType } from './task';
27
27
  export { TransitionMatch, TransitionRule, Transitions } from './transition';
28
28
  export { ConditionQueueConfig, EscalationEntry, ClaimEscalationResult, ClaimByMetadataResult, ReleaseEscalationResult, ResolveEscalationResult, CancelEscalationResult, ListEscalationsParams, CreateEscalationParams, UpdateEscalationParams, AppendMilestonesParams, ClaimEscalationParams, ClaimByMetadataParams, ReleaseEscalationParams, ResolveEscalationParams, ResolveByMetadataParams, EscalateToRoleParams, MigrateEscalationParams, } from './hmsh_escalations';
29
+ export { EscalationVerb, EngineVerb, WorkerVerb, SystemEvent, EventsConfig } from './system_events';
@@ -0,0 +1,178 @@
1
+ /**
2
+ * System-event emission surface for HotMesh lifecycle transitions.
3
+ *
4
+ * The performing actor — the one engine/SDK call that commits a durable
5
+ * transition — fires `EventsConfig.publish` exactly once, inline,
6
+ * post-commit. The hook is fire-and-forget; the SDK never awaits it.
7
+ *
8
+ * ## Ontology
9
+ *
10
+ * Every event has a canonical `type` string and a `data` payload.
11
+ * Consumers pattern-match on `type` and cherry-pick fields from `data`.
12
+ *
13
+ * ### Escalation lifecycle (`system.escalation.{id}.{verb}`)
14
+ *
15
+ * | verb | trigger | `data` shape |
16
+ * |-------------|----------------------------------------------------------|---------------------|
17
+ * | `created` | `client.create()` or hook Leg1 INSERT | full `EscalationEntry` row |
18
+ * | `claimed` | `client.claim()` / `client.claimByMetadata()` | full `EscalationEntry` row |
19
+ * | `released` | `client.release()` | full `EscalationEntry` row |
20
+ * | `reassigned`| `client.escalateToRole()` / role change | full `EscalationEntry` row |
21
+ * | `resolved` | `client.resolve()` / `client.resolveByMetadata()` | full `EscalationEntry` row |
22
+ * | `cancelled` | `client.cancel()` | full `EscalationEntry` row |
23
+ *
24
+ * ### Engine lifecycle (`system.engine.{appId}.{verb}`)
25
+ *
26
+ * | verb | trigger | `data` shape |
27
+ * |------------|----------------------------------|----------------------------------|
28
+ * | `started` | `HotMesh.init()` completes | `{ appId: string, guid: string }` |
29
+ * | `stopped` | `hotMesh.stop()` called | `{ appId: string, guid: string }` |
30
+ * | `deployed` | `kvTables.deploy()` completes | `{ appId: string }` |
31
+ *
32
+ * ### Worker lifecycle (`system.worker.{taskQueue}.{verb}`)
33
+ *
34
+ * | verb | trigger | `data` shape |
35
+ * |------------|----------------------------------|----------------------------------------------|
36
+ * | `started` | `Durable.Worker.create()` ready | `{ taskQueue: string, appId: string }` |
37
+ * | `stopped` | `worker.stop()` called | `{ taskQueue: string, appId: string }` |
38
+ *
39
+ * ## Registration — three construction sites
40
+ *
41
+ * **Site 1 — YAML DAG / hook Leg1 (escalation `created` + engine events):**
42
+ * ```typescript
43
+ * import { HotMesh } from '@hotmeshio/hotmesh';
44
+ * const hm = await HotMesh.init({
45
+ * appId: 'myapp',
46
+ * engine: { connection },
47
+ * events: { publish: (e) => bus.emit(e.type, e) },
48
+ * });
49
+ * ```
50
+ *
51
+ * **Site 2 — Standalone `EscalationClientService` (all 6 verbs):**
52
+ * ```typescript
53
+ * import { Escalations } from '@hotmeshio/hotmesh';
54
+ * const client = new Escalations.Client({
55
+ * connection,
56
+ * events: { publish: (e) => bus.emit(e.type, e) },
57
+ * });
58
+ * ```
59
+ *
60
+ * **Site 3 — `Durable.Client` (all 6 verbs via `.escalations`):**
61
+ * ```typescript
62
+ * import { Durable } from '@hotmeshio/hotmesh';
63
+ * const client = new Durable.Client({
64
+ * connection,
65
+ * events: { publish: (e) => bus.emit(e.type, e) },
66
+ * });
67
+ * // client.escalations.* operations now emit lifecycle events
68
+ * ```
69
+ *
70
+ * ## Event ID format
71
+ *
72
+ * `event_id` is collision-proof across recurrences:
73
+ * - Escalation: `${id}:${verb}:${updated_at_iso}` — claim→release→reclaim
74
+ * produces distinct IDs because `updated_at` changes on each transition.
75
+ * - Engine / worker: `${app_id_or_queue}:${verb}:${ts}`.
76
+ *
77
+ * ## Fire-and-forget contract
78
+ *
79
+ * The SDK wraps every `publish` call in
80
+ * `void Promise.resolve(publish(event)).catch(() => {})`.
81
+ * A slow or throwing `publish` implementation never blocks or fails the
82
+ * committed operation. Use an in-process buffer or async queue if you need
83
+ * back-pressure.
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * import { Durable } from '@hotmeshio/hotmesh';
88
+ * import { Client as Postgres } from 'pg';
89
+ *
90
+ * const client = new Durable.Client({
91
+ * connection: { class: Postgres, options: { connectionString: process.env.DATABASE_URL } },
92
+ * events: {
93
+ * publish: (event) => {
94
+ * if (event.type.endsWith('.created')) {
95
+ * // new escalation — route to the right team
96
+ * dispatch(event.data as EscalationEntry);
97
+ * }
98
+ * },
99
+ * },
100
+ * });
101
+ * ```
102
+ */
103
+ /** Verbs for escalation lifecycle transitions. */
104
+ export type EscalationVerb = 'created' | 'claimed' | 'released' | 'reassigned' | 'resolved' | 'cancelled';
105
+ /** Verbs for engine lifecycle transitions. */
106
+ export type EngineVerb = 'started' | 'stopped' | 'deployed';
107
+ /** Verbs for worker lifecycle transitions. */
108
+ export type WorkerVerb = 'started' | 'stopped';
109
+ /**
110
+ * Canonical lifecycle event emitted by the SDK post-commit.
111
+ *
112
+ * `event_id` is stable across replays:
113
+ * - Escalation transitions: `${id}:${verb}:${updated_at_iso}` — unique
114
+ * per transition; a re-claim after release gets a new `updated_at`.
115
+ * - Engine / worker events: `${app_id}:${verb}:${ts}`.
116
+ *
117
+ * `data` carries the full committed row (escalation entry) or lifecycle
118
+ * metadata (engine/worker). Consumers cherry-pick what they need; nothing
119
+ * is pre-projected so the shape is future-proof.
120
+ */
121
+ export interface SystemEvent {
122
+ /** Stable, unique ID per durable transition. */
123
+ event_id: string;
124
+ /**
125
+ * Canonical topic string.
126
+ *
127
+ * | Class | Pattern |
128
+ * |-------------|---------------------------------------|
129
+ * | escalation | `system.escalation.{id}.{verb}` |
130
+ * | engine | `system.engine.{appId}.{verb}` |
131
+ * | worker | `system.worker.{taskQueue}.{verb}` |
132
+ */
133
+ type: string;
134
+ /** ISO timestamp at emit time (wall-clock, post-commit). */
135
+ ts: string;
136
+ namespace: string;
137
+ app_id: string;
138
+ workflow_id?: string;
139
+ topic?: string;
140
+ origin_id?: string;
141
+ parent_id?: string;
142
+ trace_id?: string;
143
+ span_id?: string;
144
+ /**
145
+ * Full committed row for escalation events; lifecycle metadata for
146
+ * engine/worker events. Long-tail and hike-mono each cherry-pick fields
147
+ * for their own event shape.
148
+ */
149
+ data: Record<string, unknown>;
150
+ }
151
+ /**
152
+ * System-event sink configuration. Attach to `HotMeshConfig.events`,
153
+ * `EscalationClientConfig.events`, or `ClientConfig.events` to receive
154
+ * lifecycle events from the SDK.
155
+ *
156
+ * The SDK calls `publish` after each durable transition commits, from the
157
+ * single actor that performed the commit. In a multi-container fleet every
158
+ * container's SDK calls its own `publish` hook — and only for the work it
159
+ * performed — so exactly one container's `publish` fires per real event.
160
+ *
161
+ * @example
162
+ * ```typescript
163
+ * const events: EventsConfig = {
164
+ * publish: (event) => {
165
+ * // map SystemEvent → your LTEvent shape and hand to NATS/Socket.IO
166
+ * eventRegistry.publish(mapToLTEvent(event));
167
+ * },
168
+ * };
169
+ * ```
170
+ */
171
+ export interface EventsConfig {
172
+ /**
173
+ * Called post-commit by the performing actor. Fire-and-forget — the SDK
174
+ * does not await the return value; a thrown/rejected promise is silently
175
+ * swallowed so the committed call is never failed by a publish error.
176
+ */
177
+ publish: (event: SystemEvent) => void | Promise<void>;
178
+ }
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ /**
3
+ * System-event emission surface for HotMesh lifecycle transitions.
4
+ *
5
+ * The performing actor — the one engine/SDK call that commits a durable
6
+ * transition — fires `EventsConfig.publish` exactly once, inline,
7
+ * post-commit. The hook is fire-and-forget; the SDK never awaits it.
8
+ *
9
+ * ## Ontology
10
+ *
11
+ * Every event has a canonical `type` string and a `data` payload.
12
+ * Consumers pattern-match on `type` and cherry-pick fields from `data`.
13
+ *
14
+ * ### Escalation lifecycle (`system.escalation.{id}.{verb}`)
15
+ *
16
+ * | verb | trigger | `data` shape |
17
+ * |-------------|----------------------------------------------------------|---------------------|
18
+ * | `created` | `client.create()` or hook Leg1 INSERT | full `EscalationEntry` row |
19
+ * | `claimed` | `client.claim()` / `client.claimByMetadata()` | full `EscalationEntry` row |
20
+ * | `released` | `client.release()` | full `EscalationEntry` row |
21
+ * | `reassigned`| `client.escalateToRole()` / role change | full `EscalationEntry` row |
22
+ * | `resolved` | `client.resolve()` / `client.resolveByMetadata()` | full `EscalationEntry` row |
23
+ * | `cancelled` | `client.cancel()` | full `EscalationEntry` row |
24
+ *
25
+ * ### Engine lifecycle (`system.engine.{appId}.{verb}`)
26
+ *
27
+ * | verb | trigger | `data` shape |
28
+ * |------------|----------------------------------|----------------------------------|
29
+ * | `started` | `HotMesh.init()` completes | `{ appId: string, guid: string }` |
30
+ * | `stopped` | `hotMesh.stop()` called | `{ appId: string, guid: string }` |
31
+ * | `deployed` | `kvTables.deploy()` completes | `{ appId: string }` |
32
+ *
33
+ * ### Worker lifecycle (`system.worker.{taskQueue}.{verb}`)
34
+ *
35
+ * | verb | trigger | `data` shape |
36
+ * |------------|----------------------------------|----------------------------------------------|
37
+ * | `started` | `Durable.Worker.create()` ready | `{ taskQueue: string, appId: string }` |
38
+ * | `stopped` | `worker.stop()` called | `{ taskQueue: string, appId: string }` |
39
+ *
40
+ * ## Registration — three construction sites
41
+ *
42
+ * **Site 1 — YAML DAG / hook Leg1 (escalation `created` + engine events):**
43
+ * ```typescript
44
+ * import { HotMesh } from '@hotmeshio/hotmesh';
45
+ * const hm = await HotMesh.init({
46
+ * appId: 'myapp',
47
+ * engine: { connection },
48
+ * events: { publish: (e) => bus.emit(e.type, e) },
49
+ * });
50
+ * ```
51
+ *
52
+ * **Site 2 — Standalone `EscalationClientService` (all 6 verbs):**
53
+ * ```typescript
54
+ * import { Escalations } from '@hotmeshio/hotmesh';
55
+ * const client = new Escalations.Client({
56
+ * connection,
57
+ * events: { publish: (e) => bus.emit(e.type, e) },
58
+ * });
59
+ * ```
60
+ *
61
+ * **Site 3 — `Durable.Client` (all 6 verbs via `.escalations`):**
62
+ * ```typescript
63
+ * import { Durable } from '@hotmeshio/hotmesh';
64
+ * const client = new Durable.Client({
65
+ * connection,
66
+ * events: { publish: (e) => bus.emit(e.type, e) },
67
+ * });
68
+ * // client.escalations.* operations now emit lifecycle events
69
+ * ```
70
+ *
71
+ * ## Event ID format
72
+ *
73
+ * `event_id` is collision-proof across recurrences:
74
+ * - Escalation: `${id}:${verb}:${updated_at_iso}` — claim→release→reclaim
75
+ * produces distinct IDs because `updated_at` changes on each transition.
76
+ * - Engine / worker: `${app_id_or_queue}:${verb}:${ts}`.
77
+ *
78
+ * ## Fire-and-forget contract
79
+ *
80
+ * The SDK wraps every `publish` call in
81
+ * `void Promise.resolve(publish(event)).catch(() => {})`.
82
+ * A slow or throwing `publish` implementation never blocks or fails the
83
+ * committed operation. Use an in-process buffer or async queue if you need
84
+ * back-pressure.
85
+ *
86
+ * @example
87
+ * ```typescript
88
+ * import { Durable } from '@hotmeshio/hotmesh';
89
+ * import { Client as Postgres } from 'pg';
90
+ *
91
+ * const client = new Durable.Client({
92
+ * connection: { class: Postgres, options: { connectionString: process.env.DATABASE_URL } },
93
+ * events: {
94
+ * publish: (event) => {
95
+ * if (event.type.endsWith('.created')) {
96
+ * // new escalation — route to the right team
97
+ * dispatch(event.data as EscalationEntry);
98
+ * }
99
+ * },
100
+ * },
101
+ * });
102
+ * ```
103
+ */
104
+ Object.defineProperty(exports, "__esModule", { value: true });
package/index.ts CHANGED
@@ -52,3 +52,4 @@ export {
52
52
  };
53
53
 
54
54
  export * as Types from './types';
55
+ export type { EventsConfig, SystemEvent } from './types/system_events';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.22.2",
3
+ "version": "0.22.4",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -86,9 +86,10 @@
86
86
  "test:trigger": "vitest run tests/unit/services/activities/trigger.test.ts",
87
87
  "test:virtual": "vitest run tests/virtual",
88
88
  "test:unit": "vitest run tests/unit",
89
-
90
89
  "prove": "docker compose exec hotmesh npx vitest run tests/durable 2>&1 | tee /tmp/hmsh-durable.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-durable.txt | tail -5",
91
90
  "prove:escalations": "docker compose exec hotmesh npx vitest run tests/durable/escalations/postgres.test.ts 2>&1 | tee /tmp/hmsh-escalations.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-escalations.txt | tail -5",
91
+ "prove:migrations": "docker compose exec hotmesh npx vitest run tests/durable/migrations/postgres.test.ts 2>&1 | tee /tmp/hmsh-migrations.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-migrations.txt | tail -5",
92
+ "prove:events": "docker compose exec hotmesh npx vitest run tests/durable/events/postgres.test.ts 2>&1 | tee /tmp/hmsh-events.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-events.txt | tail -5",
92
93
  "prove:functional": "docker compose exec hotmesh npx vitest run tests/functional 2>&1 | tee /tmp/hmsh-functional.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-functional.txt | tail -5",
93
94
  "prove:all": "docker compose exec hotmesh npx vitest run tests/ 2>&1 | tee /tmp/hmsh-all.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-all.txt | tail -5",
94
95
  "prove:file": "f() { docker compose exec hotmesh npx vitest run \"$@\" 2>&1 | tee /tmp/hmsh-file.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-file.txt | tail -5; }; f"